Historial y boost

Por: Artiko
htmxhtmx4historialhx-boosthx-push-urlnavegacion

Historial y boost

Si tu aplicación cambia contenido sin cambiar la URL, rompes el botón “atrás”, los marcadores y la posibilidad de compartir un enlace. htmx tiene dos piezas para eso: el manejo del historial y el boost de la navegación clásica.

hx-push-url: escribir en la barra de direcciones

<a hx-get="/tareas" hx-target="#contenido" hx-push-url="true">Tareas</a>

La petición es AJAX, pero la URL del navegador pasa a /tareas y queda una entrada en el historial.

También acepta una URL explícita, útil cuando el endpoint del fragmento no es la URL que quieres mostrar:

<button hx-get="/api/fragmentos/tareas"
        hx-target="#contenido"
        hx-push-url="/tareas">
  Tareas
</button>

hx-replace-url: sin nueva entrada

Reemplaza la URL actual en lugar de añadir una entrada. Es lo correcto para filtros y búsquedas, donde no quieres que el usuario tenga que pulsar “atrás” veinte veces:

<input type="search"
       name="q"
       hx-get="/buscar"
       hx-trigger="input changed delay:400ms"
       hx-target="#resultados"
       hx-replace-url="true">
flowchart LR
    subgraph P["hx-push-url"]
        A["/inicio"] --> B["/tareas"] --> C["/tareas/5"]
        C -.->|atrás| B -.->|atrás| A
    end
    subgraph R["hx-replace-url"]
        D["/buscar?q=ca"] -.->|reemplaza| E["/buscar?q=caf"] -.->|reemplaza| F["/buscar?q=cafe"]
    end

También desde el servidor:

headers: { "HX-Push-Url": "/tareas/5" }
headers: { "HX-Replace-Url": "/buscar?q=cafe" }

El cambio grande de htmx 4: sin caché de DOM

htmx 2 guardaba snapshots del DOM en localStorage. Al pulsar “atrás”, restauraba el HTML guardado sin ir a la red. Sonaba bien y en la práctica traía tres problemas: el límite de tamaño de localStorage, snapshots obsoletos que mostraban datos viejos y contenido sensible persistido en el navegador.

htmx 4 lo eliminó: al navegar hacia atrás, vuelve a pedir el contenido al servidor.

Aspectohtmx 2htmx 4
Al pulsar “atrás”Restaura desde localStoragePetición de red
Datos obsoletosPosiblesNo
Contenido sensible en discoNo
Sin conexiónFuncionaFalla
Complejidad del coreAltaBaja

Configuración:

htmx.config.history = true;       // por defecto: refetch
htmx.config.history = "reload";   // recarga completa de la página
htmx.config.history = false;      // desactiva el manejo del historial

Si necesitas el comportamiento anterior, existe la extensión hx-history-cache, que restaura las páginas desde sessionStorage:

<script src="/js/ext/hx-history-cache.js"></script>
<meta name="htmx-config" content='extensions:"hx-history-cache"'>

sessionStorage en lugar de localStorage acota el problema del contenido sensible: se borra al cerrar la pestaña.

Qué implica en el servidor

Como el “atrás” es una petición real, tu servidor debe poder responder la URL completa. El header HX-History-Restore-Request: true te dice que es una restauración:

if (req.headers.get("HX-History-Restore-Request") === "true") {
  // Devuelve la página completa: htmx va a reemplazar el elemento de historial
  return html(paginaCompleta(contenido));
}

Este es el argumento definitivo para el patrón “una ruta, dos respuestas” del capítulo 3: si cada URL sabe servirse completa, el historial funciona solo.

hx-history-elt: qué parte se restaura

Por defecto, htmx restaura el <body>. Si tu layout tiene una cabecera y una barra lateral que nunca cambian, marca el contenedor real:

<body>
  <nav>…</nav>
  <main id="contenido" hx-history-elt>…</main>
</body>

Un solo elemento por documento debe tener hx-history-elt.

hx-boost: AJAX para enlaces y formularios

hx-boost convierte los <a> y <form> normales en peticiones htmx, conservando el HTML semántico:

<div hx-boost:inherited="true">
  <a href="/tareas">Tareas</a>
  <a href="/perfil">Perfil</a>
  <form action="/buscar" method="get">
    <input name="q">
    <button>Buscar</button>
  </form>
</div>

Qué hace exactamente:

  • Intercepta el clic y el submit.
  • Hace la petición por AJAX.
  • Reemplaza el <body> (o el hx-target que definas).
  • Empuja la URL al historial automáticamente.

Y qué pasa sin JavaScript: los enlaces y formularios funcionan como siempre. Esa es la gracia: degradación elegante gratis.

<body hx-boost:inherited="true" hx-target:inherited="#contenido">
  <nav>
    <a href="/">Inicio</a>
    <a href="/tareas">Tareas</a>
  </nav>
  <main id="contenido">…</main>
</body>

Con esas dos líneas tienes una aplicación que navega sin recargas, con URLs reales, historial funcionando e indexable por buscadores.

Excluir elementos del boost

<div hx-boost:inherited="true">
  <a href="/tareas">Interno (boosted)</a>
  <a href="/informe.pdf" hx-boost="false">Descargar PDF</a>
  <a href="https://otro-sitio.com" hx-boost="false">Enlace externo</a>
</div>

Los enlaces con target="_blank", download o hacia otro origen no se boostean automáticamente, pero conviene ser explícito.

El servidor con boost

Toda petición boosteada lleva HX-Boosted: true. Como el target suele ser un contenedor grande, el patrón es:

function responder(req, contenido, titulo) {
  const esHtmx = req.headers.get("HX-Request") === "true";

  if (esHtmx) {
    // Fragmento + título para que htmx actualice el <title>
    return html(`<title>${titulo}</title>${contenido}`);
  }
  return html(layout(contenido, titulo));
}

htmx toma el <title> de la respuesta y actualiza el de la página, salvo que uses hx-swap="… ignoreTitle:true".

Precargar la navegación

La extensión hx-preload carga el contenido al pasar el mouse por encima del enlace, de modo que al hacer clic ya está en caché:

<script src="/js/ext/hx-preload.js"></script>
<meta name="htmx-config" content='extensions:"hx-preload"'>

<a href="/tareas" hx-preload>Tareas</a>

La sensación de instantaneidad es notable y el costo es una petición extra por enlace visitado con el mouse. Úsalo en navegación principal, no en listas de cien elementos.

Restauración del scroll

htmx 4 usa la Navigation API del navegador para restaurar la posición del scroll al volver atrás. Funciona sin configuración.

Si necesitas control manual sobre el scroll de un swap concreto, están los modificadores del capítulo 6:

<div hx-get="/pagina/2" hx-swap="innerHTML show:top"></div>

Ejemplo: navegación completa

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>Panel</title>
  <meta name="htmx-config" content='extensions:"hx-preload"'>
  <script src="/js/htmx.min.js"></script>
  <script src="/js/ext/hx-preload.js"></script>
</head>
<body hx-boost:inherited="true"
      hx-target:inherited="#contenido"
      hx-swap:inherited="innerHTML show:top"
      hx-indicator:inherited="#barra">

  <div id="barra" class="htmx-indicator"></div>

  <nav>
    <a href="/" hx-preload>Inicio</a>
    <a href="/tareas" hx-preload>Tareas</a>
    <a href="/informes.pdf" hx-boost="false">Informe PDF</a>
  </nav>

  <main id="contenido" hx-history-elt>
    <!-- contenido de la página -->
  </main>
</body>
</html>

Errores comunes

SíntomaCausaSolución
El botón “atrás” muestra una página rotaEl servidor no sabe servir esa URL completaImplementar “una ruta, dos respuestas”
Cada tecleo deja una entrada en el historialSe usó hx-push-url en una búsquedaUsar hx-replace-url
El PDF se abre dentro del <main>Está boosteadohx-boost="false"
El <title> no cambia al navegarLa respuesta no incluye <title>Incluirlo en el fragmento
Se restaura el layout completo dentro del layoutFalta hx-history-eltMarcar el contenedor real
Se perdió el caché de historial al migrarCambio de htmx 4Extensión hx-history-cache

Resumen

  • hx-push-url añade entradas al historial; hx-replace-url reemplaza la actual (filtros).
  • htmx 4 eliminó la caché de DOM: al volver atrás se vuelve a pedir el contenido a la red.
  • htmx.config.history acepta true, false o "reload"; hx-history-cache restaura el comportamiento antiguo con sessionStorage.
  • hx-history-elt marca qué contenedor se restaura.
  • hx-boost:inherited="true" convierte toda la navegación en AJAX conservando la degradación elegante.
  • hx-preload precarga al pasar el mouse.
  • El patrón “una ruta, dos respuestas” es lo que hace que todo esto funcione.

Siguiente: Streaming: SSE, WebSockets y multipart →.