fix: avoid loading indicator issues when using browser history for navigation

This commit is contained in:
Björn Fromme
2026-03-16 12:00:55 +01:00
parent 685ac5a94a
commit 077bcdcaf5
9 changed files with 87 additions and 106 deletions
+2
View File
@@ -1,4 +1,6 @@
import './bootstrap.js' import './bootstrap.js'
import './loading.js'
import './styles/app.css' import './styles/app.css'
import htmx from 'htmx.org' import htmx from 'htmx.org'
-99
View File
@@ -1,99 +0,0 @@
import {Controller} from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['indicator']
static classes = ['hidden']
static values = {
visible: {
type: Boolean,
default: false
}
}
connect() {
this.debounceTimeout = null
// Bind event handlers to preserve context
this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this)
this.boundHandleAfterRequest = this.handleAfterRequest.bind(this)
this.boundHandleTimeout = this.handleTimeout.bind(this)
this.boundHandlePageShow = this.handlePageShow.bind(this)
this.boundHandleHistoryRestore = this.handleHistoryRestore.bind(this)
// Listen to HTMX events
document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
document.body.addEventListener('htmx:timeout', this.boundHandleTimeout)
document.body.addEventListener('htmx:historyRestore', this.boundHandleHistoryRestore)
// Listen for browser back/forward navigation
window.addEventListener('pageshow', this.boundHandlePageShow)
}
disconnect() {
// Clean up event listeners
document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
document.body.removeEventListener('htmx:timeout', this.boundHandleTimeout)
document.body.removeEventListener('htmx:historyRestore', this.boundHandleHistoryRestore)
window.removeEventListener('pageshow', this.boundHandlePageShow)
// Clear any pending timeout
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
}
}
handleBeforeRequest() {
// Don't start a new debounce if already visible
if (true === this.visibleValue) {
return
}
// Show indicator after 200ms delay for field refreshes
this.debounceTimeout = setTimeout(() => {
this.show()
}, 200)
}
handleAfterRequest() {
// Clear debounce timer if request completes before 200ms
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
this.debounceTimeout = null
}
this.hide()
}
handleTimeout() {
// Hide loading indicator
this.hide()
// Show user-friendly error message
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
}
handlePageShow(event) {
// event.persisted is true when page is restored from bfcache (back/forward navigation)
if (true === event.persisted) {
this.hide()
}
}
handleHistoryRestore() {
// HTMX history restore - hide loading indicator
this.hide()
}
show() {
this.visibleValue = true
this.indicatorTarget.classList.remove(this.hiddenClass)
}
hide() {
this.visibleValue = false
this.indicatorTarget.classList.add(this.hiddenClass)
}
}
+78
View File
@@ -0,0 +1,78 @@
const INDICATOR_ID = 'loading-indicator'
const HIDDEN_CLASS = 'invisible'
const GRACE_PERIOD_MS = 500
const DEBOUNCE_MS = 200
let debounceTimeout = null
let historyRestoreGracePeriod = false
function getIndicator() {
return document.getElementById(INDICATOR_ID)
}
function show() {
const indicator = getIndicator()
if (indicator) {
indicator.classList.remove(HIDDEN_CLASS)
}
}
function hide() {
if (debounceTimeout) {
clearTimeout(debounceTimeout)
debounceTimeout = null
}
const indicator = getIndicator()
if (indicator) {
indicator.classList.add(HIDDEN_CLASS)
}
}
function handleBeforeRequest() {
// Ignore requests triggered during history restore grace period
if (true === historyRestoreGracePeriod) {
return
}
// Don't start a new debounce if already pending
if (debounceTimeout) {
return
}
// Show indicator after delay
debounceTimeout = setTimeout(() => {
show()
}, DEBOUNCE_MS)
}
function handleHistoryRestore() {
hide()
// Set grace period to ignore change events triggered by history restore
historyRestoreGracePeriod = true
setTimeout(() => {
historyRestoreGracePeriod = false
}, GRACE_PERIOD_MS)
}
function handleTimeout() {
hide()
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
}
function handleBeforeHistorySave() {
// Hide indicator before HTMX saves the page to history cache
hide()
}
// Initialize event listeners
document.body.addEventListener('htmx:beforeRequest', handleBeforeRequest)
document.body.addEventListener('htmx:afterRequest', hide)
document.body.addEventListener('htmx:timeout', handleTimeout)
document.body.addEventListener('htmx:historyRestore', handleHistoryRestore)
document.body.addEventListener('htmx:sendError', hide)
document.body.addEventListener('htmx:responseError', hide)
document.body.addEventListener('htmx:beforeHistorySave', handleBeforeHistorySave)
window.addEventListener('pageshow', hide)
window.addEventListener('popstate', handleHistoryRestore)
@@ -1,5 +1,5 @@
<div class="invisible fixed top-0 left-0 inset-0 z-50 backdrop-blur-sm flex items-center justify-center transition-all duration-100" <div id="loading-indicator"
{{ stimulus_target('loading', 'indicator') }}> class="invisible fixed top-0 left-0 inset-0 z-50 backdrop-blur-sm flex items-center justify-center transition-all duration-100">
<svg class="text-primary-light w-32 h-32 lg:w-48 lg:h-48" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> <svg class="text-primary-light w-32 h-32 lg:w-48 lg:h-48" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<g fill="none" fill-rule="evenodd" stroke-width="2"> <g fill="none" fill-rule="evenodd" stroke-width="2">
<circle cx="22" cy="22" r="1"> <circle cx="22" cy="22" r="1">
+1 -1
View File
@@ -38,7 +38,7 @@
Kontaktdaten Kontaktdaten
</h2> </h2>
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
{{ form_start(personalDataForm, { 'attr': { 'data-action': 'loading#show' } }) }} {{ form_start(personalDataForm) }}
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-4"> <div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-4">
<div> <div>
{{ form_row(personalDataForm.street, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(personalDataForm.street, { 'label_attr': { 'class': 'text-white' } }) }}
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %} {% block body %}
<div class="w-screen h-dvh {% block background %}bg-outer bg-outer--2{% endblock %}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}> <div class="w-screen h-dvh {% block background %}bg-outer bg-outer--2{% endblock %}">
<div class="max-w-screen-xl h-dvh ml-auto flex flex-col overflow-hidden bg-inner"> <div class="max-w-screen-xl h-dvh ml-auto flex flex-col overflow-hidden bg-inner">
<div class="shrink-0 z-20 py-2 px-4 bg-white shadow-md"> <div class="shrink-0 z-20 py-2 px-4 bg-white shadow-md">
{% block header %} {% block header %}
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %} {% block body %}
<div class="w-screen h-dvh {% block background %}bg-outer bg-outer--2{% endblock %}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}> <div class="w-screen h-dvh {% block background %}bg-outer bg-outer--2{% endblock %}">
<div class="max-w-screen-xl h-dvh ml-auto flex flex-col overflow-hidden bg-inner"> <div class="max-w-screen-xl h-dvh ml-auto flex flex-col overflow-hidden bg-inner">
<div class="shrink-0 z-20 py-2 px-4 bg-white shadow-md"> <div class="shrink-0 z-20 py-2 px-4 bg-white shadow-md">
{% block header %} {% block header %}
+1 -1
View File
@@ -10,7 +10,7 @@
</h1> </h1>
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
<div class="pb-4"> <div class="pb-4">
{{ form_start(form, { 'attr': { 'data-action': 'loading#show' } }) }} {{ form_start(form) }}
{{ form_row(form.firstName, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(form.firstName, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(form.name, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(form.name, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }}
+1 -1
View File
@@ -10,7 +10,7 @@
</h1> </h1>
{% include '_partials/_flashes.html.twig' %} {% include '_partials/_flashes.html.twig' %}
<div class="pb-4"> <div class="pb-4">
{{ form_start(form, { 'attr': { 'data-action': 'loading#show' } }) }} {{ form_start(form) }}
{{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }}
<button type="submit" class="button button--primary"> <button type="submit" class="button button--primary">
Passwort zurücksetzen Passwort zurücksetzen