fix(runtime): fail closed on hemx request errors

Preserve pending recovery while making failed HTTP responses non-applicable as effects. The runtime now restores aria-busy with pending state and emits inspectable hemx:error details with HTTP status for failed requests.

req: runtime/005

req: convention/007
This commit is contained in:
slhx agent
2026-06-22 22:22:26 +02:00
parent 1aa92d8d9d
commit f1aa232c94
4 changed files with 50 additions and 3 deletions
+27 -1
View File
@@ -11,6 +11,7 @@
const everyTimers = new WeakMap();
const pendingClassStates = new WeakMap();
const indicatorStates = new WeakMap();
const busyStates = new WeakMap();
const disabledStates = new WeakMap();
const sseSources = new WeakMap();
const atomStores = new WeakMap();
@@ -75,6 +76,7 @@
function showPending(el, on) {
const klass = el.getAttribute("data-hemx-pending-class");
if (klass) togglePendingClass(el, klass, on);
toggleBusy(el, on);
const root = rootOf(el) || document;
forEachElement(root, (i) => { if (i.hasAttribute("data-hemx-indicator")) toggleIndicator(i, on); });
if (el.hasAttribute("data-hemx-disable-while-pending")) {
@@ -85,6 +87,23 @@
}
}
function toggleBusy(el, on) {
const state = busyStates.get(el);
if (on) {
if (state) state.count += 1;
else busyStates.set(el, { count: 1, value: el.getAttribute("aria-busy") });
el.setAttribute("aria-busy", "true");
return;
}
if (!state) return;
state.count -= 1;
if (state.count <= 0) {
if (state.value === null) el.removeAttribute("aria-busy");
else el.setAttribute("aria-busy", state.value);
busyStates.delete(el);
}
}
function togglePendingClass(el, klass, on) {
const state = pendingClassStates.get(el);
if (on) {
@@ -169,6 +188,12 @@
return url.href;
}
function httpError(response) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
return error;
}
async function send(el, eventName, source = el) {
if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return;
const { form, data, multipart } = formDataFor(el, eventName, source);
@@ -209,9 +234,10 @@
signal: abort.signal,
});
if (pending.get(target)?.abort !== abort && policy === "latest") return;
if (!response.ok) throw httpError(response);
await applyResponse(response, rootOf(target));
} catch (error) {
if (error.name !== "AbortError") emit(rootOf(target), "hemx:error", String(error));
if (error.name !== "AbortError") emit(rootOf(target), "hemx:error", { message: String(error), status: error.status || null });
} finally {
if (pending.get(target)?.abort === abort) {
pending.delete(target);