From aa79a1965667a7e6f123968d968d656da99a9058 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 13 Nov 2026 08:36:25 +0100 Subject: [PATCH] fix(app): a request whose element is gone no longer holds up every form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the desktop app "Koppel met Claude" did nothing, nor did any other form, until the window closed. A part that fetches itself sent an htmx request, and a page change replaced that part before the answer came. htmx reports the end only to the element that sent it, so app.js kept the request in its list and every POST form waited for it. The request's own loadend now counts too. Playbook: n.v.t. — a fix in Huismus's own app.js, no workflow change Co-Authored-By: Claude Opus 5.5 --- BUGS-BY-HAND.md | 1 + huismus/core/static/core/app.js | 17 ++++++++++++++--- tests/e2e/test_browser.py | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/BUGS-BY-HAND.md b/BUGS-BY-HAND.md index 013ae8e..0a3440d 100644 --- a/BUGS-BY-HAND.md +++ b/BUGS-BY-HAND.md @@ -58,3 +58,4 @@ toe (vaak in `tests/e2e/test_every_page.py`) en zet hier een regel bij. | 2026-11-06 | Nieuwe zin onder de uitgaveregels (KOR uit) bleef Nederlands in het Engels: een "%" in een templatetekst staat in de catalogus als "%%" (M21 deel 2, deze PR) | onvertaalde tekst op een scherm dat `test_every_page` niet ziet | `tests/core/test_translations.py`: elke `{% translate %}`-tekst in elke template wordt in het Engels gevonden zoals Django hem opzoekt | | 2026-11-07 | Kwartaaltabel van het jaaroverzicht rechts afgeknipt op een telefoon ("Winst", en zonder KOR "Voorbelasting", onzichtbaar); de factuurlijst 30px op 1024px (M21 deel 3, deze PR) | tabel breder dan zijn kaart wordt afgeknipt | `test_every_page`: geen `.table-wrap` met `overflow: hidden` en een bredere tabel; `.table-wrap` scrolt nu opzij | | 2026-11-07 | De eigenaar zocht de plek voor de API-sleutel van zijn eigen AI-dienst en vond hem niet: die stond onder Instellingen → "Bonnetjes lezen", een naam zonder AI of sleutel, en op AI-assistenten (waar hij keek) maakt Huismus juist zelf een sleutel (deze PR) | instelling vindbaar alleen voor wie weet waarvoor hij dient | de kaart heet "Bonnetjes lezen met AI" en noemt de API-sleutel, AI-assistenten wijst ernaar; `test_the_card_says_it_is_where_your_own_ai_key_goes`, `test_the_page_points_to_where_your_own_ai_key_goes`. Vindbaarheid zelf: geen geautomatiseerde check | +| 2026-11-13 | Desktop-app: "Koppel met Claude" deed niets, en geen enkel formulier meer, tot het venster dichtging. Een htmx-verzoek ging weg van een deel dat zichzelf ververst; een paginawissel verving dat deel voordat het antwoord kwam. htmx meldt het einde alleen aan het element zelf, dus app.js bleef wachten en hield elk POST-formulier tegen (de eigenaar; deze PR) | boekhouding van de pagina hangt aan een htmx-event van een element dat weg kan zijn | `test_browser`: `test_a_request_whose_element_is_gone_does_not_hold_up_the_forms` (geldt voor elk htmx-verzoek: app.js telt nu ook het einde van het verzoek zelf, `loadend`) | diff --git a/huismus/core/static/core/app.js b/huismus/core/static/core/app.js index 7b03ce8..c9ae622 100644 --- a/huismus/core/static/core/app.js +++ b/huismus/core/static/core/app.js @@ -122,11 +122,22 @@ const formMethod = (event) => const sending = new Set(); let waiting = false; let leaving = false; -document.addEventListener("htmx:beforeSend", (event) => sending.add(event.detail.xhr)); -document.addEventListener("htmx:afterRequest", (event) => { - sending.delete(event.detail.xhr); +const ended = (xhr) => { + if (!sending.delete(xhr)) return; if (sending.size === 0) document.dispatchEvent(new Event("huismus:idle")); +}; +// htmx:afterRequest comes first, before htmx sends a request queued behind +// this one. But htmx tells only the element that sent it, and once that +// element is gone (a part that fetches itself, replaced by a page change) the +// document never hears it. So the request's own end (loadend: an answer, an +// error or an abort) counts too; without it the request stayed here, and +// every form waited for it until the window closed (BUGS-BY-HAND.md). +document.addEventListener("htmx:beforeSend", (event) => { + const xhr = event.detail.xhr; + sending.add(xhr); + xhr.addEventListener("loadend", () => ended(xhr)); }); +document.addEventListener("htmx:afterRequest", (event) => ended(event.detail.xhr)); document.addEventListener("htmx:beforeRequest", (event) => { if (leaving) event.preventDefault(); }); diff --git a/tests/e2e/test_browser.py b/tests/e2e/test_browser.py index 8d58975..74930b6 100644 --- a/tests/e2e/test_browser.py +++ b/tests/e2e/test_browser.py @@ -235,6 +235,33 @@ def hold_the_totals(route): assert Document.objects.get().lines.get().description == "Uren" +def test_a_request_whose_element_is_gone_does_not_hold_up_the_forms(app): + # BUGS-BY-HAND.md, 2026-11-13: in the desktop app "Koppel met Claude" + # did nothing. An htmx request had gone out from an element that was gone + # by the time its answer came (a part that fetches itself every few + # seconds, replaced by a boosted page change). htmx tells only that + # element, so the document never heard the request had ended, and every + # form after it waited for it, for as long as the window stayed open. + held = [] + app.route("**/vastgelopen/", lambda route: held.append(route)) + app.goto(app.base + "/instellingen/ai-assistenten/") + with app.expect_request("**/vastgelopen/"): + app.evaluate( + """() => { + const part = document.createElement("div"); + part.setAttribute("hx-get", "/vastgelopen/"); + part.setAttribute("hx-trigger", "load"); + document.body.append(part); + htmx.process(part); + }""" + ) + app.evaluate("() => document.querySelector('[hx-get=\"/vastgelopen/\"]').remove()") + held[0].fulfill(status=200, body="") + + app.get_by_role("button", name="Koppelen").click() + expect(app.locator("#sleutel")).to_be_attached() + + def test_a_form_sent_once_ignores_the_next_click_until_back(app): # A double click made the browser cut off the first request and send the # form again; after "Leegmaken" or "Terugzetten" the second one found the