Der erste Teil der Serie behandelt die Navigation: wie man eine Symfony-Website so baut, dass zwischen den Seiten ohne Neuladen der ganzen Seite gewechselt wird, und worauf man dabei achten muss. Formulare stehen im zweiten Teil, Alpine.js im dritten. Gilt für htmx 4.
Prinzipien
Der Server liefert fertiges HTML und htmx fügt es in die Seite ein. Es braucht weder eine API für das Frontend noch eine zweite Schicht für State und Validierung, Templates, Formulare und Security bleiben in Symfony.
Jede Interaktion läuft gleich ab: Trigger → Request → Response → Auswahl eines Teils der Antwort (hx-select) → Swap in den DOM → Settle → Eintrag in die History. Attribute bestimmen, was in den einzelnen Schritten passiert.
Vorab gut zu wissen:
- Vererbung ist explizit. An Kindelemente wird nur ein Attribut mit dem Suffix
:inheritedweitergegeben. Ohne das werden Links nicht geboostet, und es erscheint kein Fehler. - Extensions werden per Import aktiviert, ein Attribut braucht es dafür nicht.
- Events haben die Form
htmx:phase:aktion, zum Beispielhtmx:before:swap. Ein Listener mit Tippfehler im Namen wird stillschweigend nie ausgelöst. - Geswappt wird alles außer 204 und 304, auch Error Pages.
- Die History speichert keine Kopien der Seiten. Zurück lädt die Seite erneut vom Server.
- Morph ist direkt im Kern.
Attribute, Events und Header nennen wir in dieser Serie nur bei konkreten Situationen. Alle sind in der Referenz von htmx 4 beschrieben.
Installation
npm install htmx.org@4
Die Einstiegsdatei ist für Vite und Webpack Encore gleich:
import htmx from 'htmx.org'
// Extensions registrieren sich am globalen htmx, deshalb erst nach dem Kern.
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-head.js'
import registerScrollRestore from './js/scroll-restore.js'
htmx.config.transitions = !matchMedia('(prefers-reduced-motion: reduce)').matches
registerScrollRestore()
htmx.initialize()müssen Sie nicht aufrufen, htmx startet nach der Auswertung des Moduls von selbst. Extensions und Listener registrieren Sie direkt im Modul.- Skripte gehören in den
<head>. Beim Morph des<body>-Inhalts würde sich ein Skript am Ende der Seite wie ein gewöhnliches Element verhalten.
Navigation
Grundeinstellung
Den meisten Websites genügt eine einzige Einstellung am <body>:
<body hx-boost:inherited="true"
hx-swap:inherited="innerMorph"
hx-head:inherited="merge">
Die Controller ändern sich nicht und liefern ganze Seiten. Genauso kann auch eine Suche mit Filtern und Paginierung als gewöhnliche GET-Formulare und Links laufen, ohne Fragmente und ohne Auswertung von Headern auf dem Server.
hx-swap:inherited erben auch Elemente mit eigenem hx-get oder hx-post, sie sollten deshalb ein eigenes hx-swap haben.
Wenn in der Seite etwas außerhalb des Swaps bleiben muss, zum Beispiel von einem fremden Skript eingefügte Elemente, swappen Sie nur den Hauptcontainer:
<head hx-head="merge">
<body hx-boost:inherited="target:'#app', select:'#app', swap:'outerMorph ignoreTitle:true'">
<div id="app" hx-history-elt>
hx-head="merge"ist hier nötig. Wenn das Target nicht<body>ist, ergänzthx-headden Head nur, und Titel sowie Meta-Tags häufen sich an.- Das Target geben Sie per Selektor an, nicht mit
target:this. hx-targetundhx-selectgehören nicht an#app, sonst würden alle Kindelemente sie erben.- Ohne
hx-history-eltwürde Zurück den ganzen<body>austauschen.
Was boosten und was nicht
Der Boost betrifft Links und Formulare auf demselben Origin. Er lässt Links mit download, mit einem anderen target als _self und Links mit # am Anfang aus. Klicks mit Strg, Cmd oder Umschalt überlässt er dem Browser.
hx-boost="false" gehört an:
- Links in die Administration oder einen anderen Bereich mit eigenen Assets,
- Seiten mit völlig anderem Layout, zum Beispiel die Anmeldung ohne Header und Footer,
- das Anmeldeformular und Links, die etwas ändern (Abmelden per GET),
- einen Umschalter, der State nur im Browser verwaltet.
Ein Morph zwischen ungleichen Layouts ist ein unnötiges Risiko, ein vollständiges Laden ist zuverlässiger.
Morph und Identität der Elemente
Morph paart Elemente nach Tag und id. Ein Element ohne id wird mit jedem Element mit demselben Tag an derselben Stelle zusammengeführt. Ein Element mit id wird nur mit einem Element mit derselben id zusammengeführt, sonst wird es neu eingefügt und das alte verschwindet.
Bewährt hat sich, dem Hauptcontainer des Inhalts eine id nach der Route zu geben:
<main id="content-{{ app.request.attributes.get('_route')|default('page') }}">
{% block content %}{% endblock %}
</main>
Dieselbe Route wird gemorpht und der State bleibt, eine andere Route tauscht den Inhalt sauber aus. Navigation, Sidebar und Footer bleiben außerhalb des Containers, ihr State übersteht den Wechsel. Worauf Sie achten sollten:
- Ein Container ohne
idoder mit gemeinsameridüberträgt State zwischen nicht zusammengehörenden Elementen. Der Text aus dem Feldsearcherscheint im Feldemailauf der nächsten Seite, ein Passwort wird lesbar in einem Textfeld angezeigt (Morph änderttype, den Wert lässt er) und ein Listener direkt am Button „Löschen“ wird nach dem Klick auf den neuen Button „Teilen“ ausgelöst. - Gleiche innere
idunter verschiedenen Containern. Hat ein Element im Container auf beiden Seiten dieselbeid, überträgt Morph es samt State in den neuen Container, und die Komponente darin kann ihren State verlieren. Wählen Sieidinnerhalb der Seite deshalb so, dass sie sich zwischen verschiedenen Seiten nicht wiederholen, sofern das Element den Wechsel nicht absichtlich überstehen soll. - Formulare sollten eine eigene
idhaben, beim Anmeldeformular ist das Pflicht. - Listener hängen Sie per Delegation an
document, oder Sie entfernen sie vor dem Swap. - Morph löscht Attribute, die nicht in der Antwort stehen. Ein im Browser gesetztes Flag wie
data-map-initializedverschwindet und das Widget wird ein zweites Mal initialisiert. Nehmen Sie es inhtmx.config.morphIgnoreauf (das Standard-Array erweitern, nicht überschreiben), oder ermitteln Sie den State über die Bibliothek, zum BeispielChart.getChart(canvas). - Morph behält die Werte von Formularfeldern, er eignet sich also nicht zum Zurücksetzen eines Formulars (siehe Teil 2).
- Attributnamen müssen durch
setAttribute()gehen. Morph kopiert damit Attribute, und WebKit lehnt einen Namen ab, den der HTML-Parser akzeptiert, zum Beispiel@clickaus Alpine.js. Morph bricht dann mittendrin ab, ohne Fehler in der Konsole. Details stehen im dritten Teil.
Ein Element nehmen Sie mit dem Attribut hx-morph-skip vom Morph aus, hx-morph-skip-children aktualisiert nur seine Attribute.
Head, Titel und Sprache
Die Extension hx-head führt den <head> nach exakter Übereinstimmung der Tags zusammen. Titel, Description, canonical und JSON-LD werden nach dem Wechsel ausgetauscht.
- Analytics und andere einmalige Skripte im Head markieren Sie mit
hx-preserve="true", sonst würden sie bei jedem Wechsel gelöscht und erneut ausgeführt. <html lang>ändert sich nicht, weil htmx nur den Inhalt von<body>swappt. Nach dem Sprachwechsel bliebe die alte Sprache für Screenreader und Übersetzer. Geben Sie dem Sprachumschalterhx-boost="false", oder setzen Sielangaus der Antwort:
document.addEventListener('htmx:before:swap', (event) => {
const lang = event.detail.ctx.text?.match(/<html[^>]*\slang="([^"]*)"/i)?.[1]
if (lang) document.documentElement.lang = lang
})
- htmx schreibt den Titel erst nach dem Swap, mit aktivierten View Transitions erst nach dem Ende des Übergangs. Ein zweiter schneller Klick kann dann im Tab den Titel der ersten Seite stehen lassen.
ignoreTitle:trueinhx-swapüberlässt den Titel nurhx-head, das ihn sofort setzt. - CSS nicht aus per Lazy-Import geladenem JavaScript laden. Vite fügt es in den
<head>ein, der nächste Wechsel entfernt es als unbekannt und es wird nicht wieder hinzugefügt. Lazy laden Sie nur JavaScript.
Menü nach dem Wechsel
Ein Dropdown-Menü über :hover und :focus-within bleibt nach dem Klick auf einen Link darin offen, weil der Link den Fokus auch nach dem Wechsel behält. Beim vollständigen Laden der Seite geht der Fokus von selbst verloren, mit htmx muss man ihn entfernen:
document.addEventListener('htmx:before:request', (event) => {
if (event.detail.ctx.request.headers['HX-Boosted']) document.activeElement?.blur()
})
Prefetch
Die Extension hx-preload lädt die Seite schon bei mousedown oder touchstart. Bevor der Nutzer die Taste loslässt, ist der Inhalt unterwegs oder fertig. Für geboostete Links genügt es, die Extension zu laden, das Attribut braucht es nur, um das Verhalten zu ändern:
<body hx-preload:inherited="mousedown, touchstart">
Prefetch ist ein echter Request und nutzt keinen HTTP-Cache, Sie müssen dafür also kein Cache-Control setzen. Er betrifft nur GET.
Aktionen, die etwas ändern, vom Prefetch ausnehmen. Prefetch unterscheidet keine Maustasten und startet auch bei Rechtsklick, Mittelklick oder dem Beginn eines Scrollens auf dem Smartphone. Ein Link per GET würde so eine Aktion ausführen, die der Nutzer nicht wollte:
- Abmelden,
- Hinzufügen zum Warenkorb oder zu den Favoriten,
- Löschen, Archivieren, Als gelesen markieren,
- Abstimmen, Bestätigen oder Abbestellen,
- Umschalten von Sprache oder Währung, das in der Session gespeichert wird,
- ein Export, dessen Erzeugung auf dem Server teuer ist,
- alles, was eine Flash-Nachricht abholt.
Am saubersten sind solche Aktionen als Formular per POST mit CSRF-Token, Prefetch betrifft POST nicht. Muss es ein Link bleiben, nehmen Sie ihn aus: hx-preload="none" schaltet nur den Prefetch ab und lässt den Boost, hx-boost="false" schaltet beides ab. Abmelden und Sprachwechsel gehören eher zu hx-boost="false", weil sie die ganze Seite ändern.
Weitere Folgen:
- Aufrufzähler zählen leicht zu hoch, auch ein abgebrochenes Drücken wird mitgezählt. Prefetch auf dem Server nicht herausfiltern: Nach dem Klick ist er oft der einzige Request, es würden also fast alle echten Aufrufe verschwinden. Auf einer Liste, auf der die Scrollgeste oft auf einem Link beginnt, schränken Sie die Trigger mit
hx-preload:inherited="mousedown"ein. - Die Standardgültigkeit von 5 Sekunden nicht verlängern, wo persönlicher State im Spiel ist. Einen Link ohne
idbehält Morph samt geladener Antwort, eine längere Gültigkeit würde nach einem Kauf also den alten Kontostand zeigen. Auf einer reinen Content-Website lässt sie sich verlängern, zum Beispielmousedown timeout:30s. - Prefetch auf Hover lohnt sich nicht. Die Extension hat keine Verzögerung, ein Überfahren des Menüs rendert also alle Seiten dahinter. Eine eigene Verzögerung führte zu doppelten Requests und brachte gegenüber
mousedownnur einige zehn Millisekunden.
View Transitions
htmx.config.transitions = true schaltet View Transitions ein. Mit der Standarddauer dauert ein Übergang etwa 300 ms, was auf einer Website mit Prefetch langsamer wirkt, als sie ist. Kürzen Sie alle drei Pseudo-Elemente, die Dauer des ganzen Übergangs bestimmt group:
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 100ms;
}
- Bei
prefers-reduced-motionschalten Sie Übergänge gar nicht ein. Schon der Snapshot friert die Seite für die Dauer des Übergangs ein, auch wenn die Animation im CSS abgeschaltet ist. - Ein Element mit eigenem
view-transition-namewird separat animiert. Die Unterstreichung des aktiven Menüpunkts als eigenes<span>mit eigenem Namen gleitet so zwischen den Seiten, einschließlich der Breitenänderung. Der Name muss im Dokument eindeutig sein, zwei gleiche Namen brechen den Übergang ab. - Verwenden Sie kein globales
scroll-behavior: smooth. Die Seite würde nach jedem Wechsel und nach Zurück langsam scrollen. Smooth Scroll schalten Sie nur bei konkreten Links ein.
Error Pages
htmx swappt auch eine Fehlerantwort, ein geboosteter Link auf eine nicht existierende Seite zeigt also Ihre 404-Seite.
- Die Templates in
templates/bundles/TwigBundle/Exception/müssen das ganze Layout haben. - In den Umgebungen
devundtestwerden sie nicht gerendert, weilkernel.debugläuft. Testen Sie sie auf einem Build fürprod. - Bei der Variante mit
#appwürde eine Antwort ohne#appden Inhalt löschen und eine leere Seite hinterlassen. Das passiert bei einem 502 vom Proxy während eines Deployments, bei einem fatalen PHP-Fehler oder bei der Fallback-Error-Page von Symfony. Eine Absicherung übergibt die Navigation an den Browser:
document.addEventListener('htmx:before:swap', (event) => {
const { ctx, tasks } = event.detail
if (ctx.target?.id !== 'app') return
const main = tasks.find((task) => task.type === 'main')
if (main && main.fragment.childElementCount === 0) {
event.preventDefault()
window.location.assign(ctx.request.action)
}
})
Abgelaufene Session
Wenn die Session abläuft, leitet Symfony auf den Login um und fetch folgt dem Redirect von selbst. Für einen geboosteten Link mit Login im selben Layout funktioniert das ohne Anpassung. Hat der Login ein anderes Layout oder wird ein Fragment mit eigenem Target geladen, würde die Anmeldeseite an einer fremden Stelle eingefügt. Es hilft, wenn der AuthenticationEntryPoint bei htmx ein vollständiges Laden erzwingt:
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
$loginUrl = $this->urlGenerator->generate('app_login');
if ($request->headers->has('HX-Request')) {
return new Response('', Response::HTTP_OK, ['HX-Redirect' => $loginUrl]);
}
return new RedirectResponse($loginUrl);
}
Symfony speichert vor diesem Aufruf die Adresse des Requests als Ziel nach der Anmeldung. Bei einem Fragment per GET käme der Nutzer nach der Anmeldung auf das nackte Fragment, löschen Sie das Ziel in diesem Zweig deshalb mit TargetPathTrait::removeTargetPath().
Zurück und Scrollposition
htmx speichert beim Wechsel keine Kopie der verlassenen Seite, Zurück lädt sie also erneut vom Server. Der Back/Forward Cache des Browsers hilft nicht, er funktioniert nur beim Wechsel zwischen Dokumenten.
- In einem Browser mit Navigation API fängt htmx die Navigation ab, lädt die Seite und swappt sie. In Firefox und Browsern ohne diese API übernimmt
popstatedieselbe Arbeit. - Der Request trägt nur
HX-History-Restore-Request,HX-RequestundHX-Boostedfehlen. Der Server liefert also die ganze Seite ohne Anpassung. - Der Swap läuft über
outerSync, nicht über Morph. Der State von Komponenten und angefangene Eingaben gehen verloren.
Daraus folgt:
- State, der Zurück überstehen soll, gehört in die URL. Filter, Seite und Suche müssen in der Adresse stehen. Ein Fragment mit
hx-push-urlbraucht eine Adresse, unter der der Server die ganze Seite liefert. - Widgets müssen sich abbauen und neu erstellen lassen. Diagramme bauen Sie in
htmx:before:swapab und inhtmx.onLoadneu auf. - Aufräumen von State außerhalb des Swaps gehört ebenfalls in
htmx:before:swap, zum Beispiel das Entfernen einer Klasse, die bei geöffnetem Menü das Scrollen sperrt. - Ohne Request an den Server geht Zurück mit der Extension
hx-history-cache, die Seiten insessionStoragespeichert, Zurück sendet dann keinen Request. Der Preis ist, dass die Daten nach der Rückkehr nicht aktuell sein müssen.
Scroll Restoration
Der Browser stellt die Scrollposition erst nach dem Ende der abgefangenen Navigation wieder her, die Seite wird also zuerst oben gerendert und springt dann nach unten. Firefox macht es umgekehrt: Er scrollt noch den alten Inhalt, und von einer längeren auf eine kürzere Seite wird die Position nach dem Swap abgeschnitten. Die Lösung ist, sich die Position selbst nach dem Schlüssel des History-Eintrags zu merken:
const STORAGE_KEY = 'scroll-positions'
const LIMIT = 50
function load() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
} catch (e) {
return {}
}
}
function store(positions) {
const keys = Object.keys(positions)
for (const key of keys.slice(0, Math.max(0, keys.length - LIMIT))) delete positions[key]
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
} catch (e) { /* privates Fenster: die Position wird nur bis zum Reload wiederhergestellt */ }
}
export default function registerScrollRestore() {
if (!window.navigation) return
const positions = load()
let pending = null
// Dieselbe Bedingung, unter der htmx statt die Navigation abzufangen auf popstate hört.
if (/firefox/i.test(navigator.userAgent)) {
history.scrollRestoration = 'manual'
const isHistoryLoad = performance.getEntriesByType('navigation')[0]?.type === 'back_forward'
const target = positions[navigation.currentEntry.key]
if (isHistoryLoad && target !== undefined) window.scrollTo(0, target)
// Das Verlassen über die Adressleiste löst das Event navigate nicht aus.
window.addEventListener('pagehide', () => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
})
}
// Muss vor dem Listener von htmx registriert werden, sonst kommt scroll: 'manual' zu spät.
navigation.addEventListener('navigate', (event) => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
const isHistoryRestore = event.navigationType === 'traverse' && event.canIntercept && !event.hashChange
// Einträge mit derselben Adresse fügt ein modales Fenster hinzu: die Seite ändert sich nicht, kein Swap.
if (isHistoryRestore && event.destination.url === navigation.currentEntry.url) {
event.intercept({ scroll: 'manual' })
return
}
const target = positions[event.destination.key]
if (!isHistoryRestore || target === undefined) return
pending = target
event.intercept({ scroll: 'manual' })
})
const restore = () => {
if (pending === null) return
window.scrollTo(0, pending)
pending = null
}
// Mit View Transition kommt htmx:after:swap erst nach dem Übergang. Die Scrollposition
// gehört deshalb ans Ende seines Tasks, damit schon der neue Snapshot richtig gescrollt ist.
document.addEventListener('htmx:before:viewTransition', (event) => {
if (pending === null) return
const task = event.detail.task
event.detail.task = async () => {
await task()
restore()
}
})
document.addEventListener('htmx:after:swap', restore)
}
Browser ohne Navigation API lässt das Modul aus, dort stellt der Browser die Position selbst wieder her, mit dem beschriebenen Sprung. Modale Fenster, die mit der History verknüpft sind, stehen im dritten Teil.
Lazy Loading
Es lohnt sich bei Inhalten, die groß oder aufwendig in der Erstellung sind und die die meisten Besucher nicht öffnen, zum Beispiel ein Preisverlaufsdiagramm, die Bestellhistorie oder die Vorschau eines Dokuments in einem eingeklappten Abschnitt:
<details>
<summary>Preisverlauf</summary>
<div hx-get="{{ path('app_product_price_history', {id: product.id}) }}"
hx-trigger="toggle once from:'closest details'"
hx-swap="innerHTML"></div>
</details>
#[Route('/produkt/{id}/preisverlauf', name: 'app_product_price_history', requirements: ['id' => '\d+'], methods: ['GET'])]
public function priceHistory(#[MapEntity(id: 'id')] Product $product): Response
{
$response = $this->render('product/_price_history.html.twig', ['product' => $product]);
$response->setPublic();
$response->setMaxAge(3600);
$response->headers->set('X-Robots-Tag', 'noindex');
return $response;
}
- Ein Selektor aus mehreren Wörtern in
hx-triggermuss in Anführungszeichen stehen. Klammern kann htmx nicht, und ein Fehler beim Parsen stoppt die Verarbeitung der restlichen Seite. - Weitere nützliche Trigger:
load(sofort nach dem Einfügen),revealed(wenn das Element in den Viewport kommt),intersectmit Schwellenwert. - Wenn ein Wert aus der Adresse ein Template oder eine Datei bestimmt, schränken Sie ihn über
requirementsein. - Reservieren Sie Platz für den Inhalt, damit sich die Seite nach dem Laden nicht verschiebt.
- Ein Fragment ohne personenbezogene Daten kann öffentlich gecacht werden.
JavaScript für neuen Inhalt
- Widgets initialisieren Sie in
htmx.onLoad(callback). Es läuft beim ersten Laden und nach jedem Swap, auch nach Zurück. Die Initialisierung muss wiederholbar sein. htmx:after:swapwird an dem Element ausgelöst, das den Request gesendet hat, nicht am Target. Für die Initialisierung neuen Inhalts eignet es sich nicht.- Schwere Bibliotheken (Syntax-Highlighting, Karten, Diagramme) laden Sie per
import(), erst wenn ihr Element auf der Seite ist. Im Haupt-Bundle bleibt nur die Bedingung:
function highlightCode(root = document) {
if (!root.querySelector?.('pre code:not([data-highlighted])')) return
import('./js/code-highlight.js').then(({ default: highlight }) => highlight(root))
}
highlightCode()
htmx.onLoad(highlightCode)
SEO
htmx ist für Suchmaschinen unsichtbar, solange eine einzige Regel gilt: Jede Adresse liefert ohne JavaScript die ganze Seite. Der Crawler sendet kein HX-Request, er bekommt also dasselbe wie der Nutzer beim ersten Laden.
curl -s https://example.com/leistungen | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/gibt-es-nicht | head -1
- Links sind
<a href>. Der Crawler folgt keinemhx-getan einem Button oder<div>. - Was gefunden werden soll, steht im ersten HTML. Der Crawler klickt nicht, scrollt nicht und öffnet keinen Abschnitt. Per Lazy Loading laden Sie nur Ergänzungen. Text, der gefunden werden soll, lassen Sie im HTML, auch wenn er in einem eingeklappten
<details>steht. Viele AI-Crawler führen JavaScript gar nicht aus. - Titel, Description,
canonicalundhreflangstehen in der Antwort jeder Adresse,hx-headtauscht sie dann auch für den Nutzer aus. - Echte Statuscodes. 404 muss 404 sein, ein Umzug 301. Eine Error Page mit Status 200 käme in den Index.
- Fragmente gehören nicht in den Index. Eigene Routen bekommen
X-Robots-Tag: noindexund stehen nicht in der Sitemap. Sperren Sie sie nicht inrobots.txt, sonst liest der Crawler den Header nicht. - Core Web Vitals hängen vor allem am ersten Laden, nicht an den Wechseln. Achten Sie auf die Größe des Bundles und die Antwortzeit des Servers, weil Prefetch und Zurück über ihn laufen.
Paginierung und Filter aus SEO-Sicht beschreibt Teil 2.
Messung
Ein Wechsel per htmx ist kein Laden der Seite, Analytics zählt die Aufrufe also nicht von selbst.
- Einen eigenen Pageview senden Sie nach
htmx:after:swap, nur wenn sich die Adresse geändert hat. Ein geöffnetes Fenster oder ein abgeschicktes Formular zählt dann nicht. - Der Titel ist in diesem Moment schon neu,
hx-headhat ihn bereits bei der Antwort zusammengeführt. Events nach dem Eintrag in die History kommen noch vor dem Swap, wenn die Seite den alten Inhalt zeigt. - Google Tag Manager erfasst Wechsel über den Trigger History Change. Mit einem eigenen Senden haben Sie zusätzlich zwei Aufrufe.
- Zurück zählt als Aufruf.
- Das Analytics-Skript markieren Sie mit
hx-preserve="true".
Tests und Deployment
- Vor einem Update von htmx testen Sie einen Wechsel und das Absenden eines Formulars auch in einem älteren Browser und gehen die Vorkommen von
htmx:in den Templates durch. - In End-to-End-Tests warten Sie auf
htmx:finally:requestund hängen den Listener im selben Skript vor dem Klick an. Das Warten auf das Verschwinden der Klasse.htmx-requestdirekt nach dem Klick kann enden, bevor die Klasse überhaupt erscheint. - Zurück testen Sie in Chrome und Firefox, es läuft dort über anderen Code. Testen Sie mit View Transitions und mit
prefers-reduced-motion. - Wechsel testen Sie auch in WebKit. Ein Fehler beim Morph muss dort nichts ausgeben, Sie erkennen ihn daran, dass nach dem Klick ein Teil der alten Seite stehen bleibt.
- Error Pages testen Sie auf
prodund einmal auch einen Ausfall ohne Ihr Template, zum Beispiel einen 502 vom Proxy.
Checkliste
hx-headundhx-preloadnach dem Kern importiert, Skripte im<head>, einmalige mithx-preserve="true"<body>mithx-boost:inheritedund Morph, oder#appmithx-head="merge"undhx-history-elt- Hauptcontainer mit
idnach der Route, innereidwiederholen sich nicht zwischen Seiten hx-boost="false"an Abmelden, Anmelden, Administration und anderen Layouts- Aktionen, die etwas ändern (Warenkorb, Löschen, Abstimmen…), per POST, sonst
hx-preload="none" <html lang>entspricht nach dem Sprachwechsel der SeiteAuthenticationEntryPointliefert bei htmxHX-Redirect, wenn der Login ein anderes Layout hat oder Fragmente hinter der Anmeldung liegen- Error Pages mit ganzem Layout, bei
#appAbsicherung gegen eine leere Auswahl - Gekürzte View Transitions, abgeschaltet bei
prefers-reduced-motion, kein globalesscroll-behavior: smooth - Modul für Scroll Restoration vor dem Start von htmx registriert
- State, der Zurück überstehen soll, steht in der URL
- Widgets in
htmx.onLoad, wiederholbare Initialisierung, Client-Flags inmorphIgnore - SEO:
curlliefert die ganze Seite, Links sind<a href>, Hauptinhalt im ersten HTML, Fragmente mitnoindex - Pageview nach
htmx:after:swap, nicht nach der Änderung der History - Zurück in Chrome und Firefox getestet, Wechsel auch in WebKit, Error Pages auf
prod