Skip to content

Session expiry

The session that expired over lunch should not eat the click that discovered it. In this recipe any protected action’s 401 carries a login <dialog> retargeted at a shared host — installRemoteDialog (shipped) opens it — and the auto-installed installSessionExpiry() remembers the interrupted request and replays it once the login response fires hc:sessionrenewed. The user’s action completes instead of vanishing. Covered by the versioning policy.

Also known as: session timeout, re-login flow.

The demo session starts expired: click Approve, sign in (any password; wrong fails inline), and watch the approval complete by itself. Expire session re-arms the flow.

The session starts expired — click Approve.

<button class="hc-button" data-variant="primary" type="button"
data-hx-post="/tickets/7/approve"
data-hx-target="#status" data-hx-swap="innerHTML">
Approve
</button>
<p class="hc-field__hint" id="status" aria-live="polite"></p>
<!-- One shared host at body end -->
<div id="error-dialog" data-hc-remote-dialog-root data-hc-session-expiry></div>

The host wears two hats: data-hc-remote-dialog-root opens whatever dialog the server swaps in; data-hc-session-expiry arms the replay bridge.

CaseResponse
protected endpoint, session expired, htmx request401 + HX-Retarget: #error-dialog + HX-Reswap: innerHTML + the login dialog fragment. The server must 401 before acting — replay safety depends on it
login success200 + empty body + HX-Trigger: {"hc:sessionrenewed": {}} — the bridge closes the dialog and replays
login failure422 + HX-Retarget: #error-dialog + HX-Reswap: innerHTML + the dialog re-rendered with field-errors inline — the login form posts with data-hx-target="this" / data-hx-swap="none", so without the headers the body would be swallowed
non-htmx request, session expired303 to the login page with ?next=

One slot, latest wins: parallel interruptions replay only the last. The replay goes through htmx.ajax(verb, path, { source, values }), re-running the full pipeline — a rotated CSRF token is picked up fresh by installCsrfHeader, indicators and targets behave as if the user clicked again. Multi-value fields collapse to their last value; if the interrupted element left the DOM, the replay is skipped. Nothing survives a full page load.

  • The dialog is a real <dialog> via showModal() — native focus trap and Escape; aria-labelledby names it.
  • Cancel is a <form method="dialog"> button — declarative, CSP-safe.
  • The replayed outcome lands in the action’s own aria-live target, so completion is announced.