From 59e10005f9d1368dbfc3d3bcce3d57a4afd8c1b9 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Wed, 18 Mar 2026 16:14:58 -0400 Subject: [PATCH 1/3] Replace custom JS toast system with HTMX-native patterns Migrate showToast and flash cookie JS to server-side rendering with htmx-ext-remove-me for auto-dismiss, HTMX responseHandling config for error toasts, and a flash cookie middleware for full-page-load toasts. Replace HX-Refresh on avatar update with OOB swaps for both the profile card and navbar avatar, eliminating the visible flicker from full page reloads and ensuring the success toast is always displayed. Co-Authored-By: Claude Opus 4.6 (1M context) --- main.py | 18 +- routers/core/user.py | 16 +- static/js/app.js | 61 +---- templates/base.html | 13 ++ templates/base/partials/header.html | 9 +- templates/base/partials/navbar_avatar.html | 9 + .../base/partials/navbar_avatar_oob.html | 9 + templates/base/partials/toast.html | 3 +- templates/users/partials/profile_form.html | 24 +- tests/browser/test_toast_display.py | 220 ++++++++++++++++++ tests/test_htmx.py | 10 +- 11 files changed, 318 insertions(+), 74 deletions(-) create mode 100644 templates/base/partials/navbar_avatar.html create mode 100644 templates/base/partials/navbar_avatar_oob.html create mode 100644 tests/browser/test_toast_display.py diff --git a/main.py b/main.py index 375d9a3..3741b53 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ require_unauthenticated_client ) from utils.core.auth import COOKIE_SECURE -from utils.core.htmx import is_htmx_request, toast_response +from utils.core.htmx import is_htmx_request, toast_response, get_flash_cookie, FLASH_COOKIE_NAME from exceptions.http_exceptions import ( AlreadyAuthenticatedError, AuthenticationError, @@ -47,6 +47,22 @@ async def lifespan(app: FastAPI): templates = Jinja2Templates(directory="templates") +# --- Flash cookie middleware --- +# Reads the flash cookie into request.state so templates can render it +# server-side, then clears the cookie on the response. + + +@app.middleware("http") +async def flash_cookie_middleware(request: Request, call_next): + flash = get_flash_cookie(request) + request.state.flash = flash + response = await call_next(request) + if flash: + response.delete_cookie(FLASH_COOKIE_NAME, path="/") + return response + + + # --- Include Routers --- diff --git a/routers/core/user.py b/routers/core/user.py index 6d6e77c..6a2003a 100644 --- a/routers/core/user.py +++ b/routers/core/user.py @@ -141,17 +141,21 @@ async def update_profile( session.refresh(user) if is_htmx_request(request): - if avatar_changed: - # Avatar affects the navbar, which is outside the swap target. - # Tell HTMX to do a full page refresh so everything updates. - response = Response(status_code=200) - response.headers["HX-Refresh"] = "true" - return response response = templates.TemplateResponse( request, "users/partials/profile_display.html", {"user": user}, ) + if avatar_changed: + # Avatar also appears in the navbar — append an OOB swap for it. + navbar_html = bytes(templates.TemplateResponse( + request, + "base/partials/navbar_avatar_oob.html", + {"user": user}, + ).body).decode() + original = bytes(response.body).decode() + response.body = (original + navbar_html).encode() + response.headers["content-length"] = str(len(response.body)) return append_toast(response, request, templates, "Profile updated successfully.") return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303) diff --git a/static/js/app.js b/static/js/app.js index 2b60d96..8d66d4d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3,58 +3,19 @@ // re-processed during htmx hx-boost body swaps, which means the event // listeners registered here persist across page navigations. -function showToast(message, level) { - level = level || 'success'; - var container = document.getElementById('toast-container'); - var wrapper = document.createElement('div'); - wrapper.className = 'toast align-items-center text-bg-' + level + ' border-0 show'; - wrapper.setAttribute('role', 'alert'); - wrapper.setAttribute('aria-atomic', 'true'); - wrapper.innerHTML = - '
' + - '
' + message + '
' + - '' + - '
'; - container.appendChild(wrapper); - setTimeout(function() { wrapper.remove(); }, 5000); -} - -// For HTMX error responses, extract and apply OOB swaps (toasts) without -// touching the main target. We parse the response HTML, find elements with -// hx-swap-oob, and swap them in manually via htmx.process(). -document.body.addEventListener('htmx:beforeSwap', function(evt) { - if (evt.detail.xhr.status >= 400) { - evt.detail.shouldSwap = false; - evt.detail.isError = false; - var responseText = evt.detail.xhr.responseText; - if (responseText) { - var doc = new DOMParser().parseFromString(responseText, 'text/html'); - var oobElements = doc.querySelectorAll('[hx-swap-oob]'); - oobElements.forEach(function(el) { - var targetId = el.getAttribute('id'); - if (targetId) { - var existing = document.getElementById(targetId); - if (existing) { - existing.replaceWith(el); - htmx.process(el); - } - } - }); - } +// Configure HTMX to process response bodies on error status codes so that +// OOB-swapped toasts are applied. swapOverride:'none' ensures the main +// target is left untouched while OOB elements are still processed. +document.body.addEventListener('htmx:configRequest', function() { + if (!htmx.config.responseHandling.find(function(r) { return r.code === '400'; })) { + htmx.config.responseHandling = [ + { code: '204', swap: false }, + { code: '[23]..', swap: true }, + { code: '[45]..', swap: true, error: false, swapOverride: 'none' }, + ]; } -}); +}, { once: true }); -// Read flash cookie on page load -(function() { - var raw = document.cookie.split('; ').find(function(c) { return c.startsWith('flash_message='); }); - if (!raw) return; - var value = decodeURIComponent(raw.split('=').slice(1).join('=')); - document.cookie = 'flash_message=; Max-Age=0; path=/'; - try { - var flash = JSON.parse(value); - if (flash && flash.message) showToast(flash.message, flash.level); - } catch(e) {} -})(); // Global handler: when a server response includes HX-Trigger: modalDismiss, // clean up any Bootstrap modal backdrop left behind by OOB swaps that diff --git a/templates/base.html b/templates/base.html index 49e1fbf..cfdcfae 100644 --- a/templates/base.html +++ b/templates/base.html @@ -21,13 +21,26 @@ order and waits for DOM parsing to complete. --> + {% block extra_head %}{% endblock %}
+ {% set flash = request.state.flash %} + {% if flash %} + + {% endif %}
diff --git a/templates/base/partials/header.html b/templates/base/partials/header.html index 7509bef..b16c7e5 100644 --- a/templates/base/partials/header.html +++ b/templates/base/partials/header.html @@ -1,5 +1,4 @@ {% from 'base/macros/logo.html' import render_logo %} -{% from 'base/macros/silhouette.html' import render_silhouette %}