From de6ecd469f8a4b85a0766f2a2d066714e7611779 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 22 Jun 2026 22:26:33 +0200 Subject: [PATCH] fix(runtime): show transport failures in app error outlets Make failed hemx requests visibly recoverable by updating root-scoped data-hemx-error outlets while preserving fail-closed non-2xx handling and inspectable hemx:error events. The v0 generated-app exemplar now includes a transport error outlet alongside generated field-error targets. req: runtime/005 req: examples/001 req: canonical_authoring/003 --- AGENTS.md | 2 +- REQUIREMENTS.md | 2 +- examples/v0/src/main.rs | 7 ++++++- examples/v0/templates/todos.heml | 1 + hemx-build/src/lib.rs | 1 + hemx-js/runtime/hemx.js | 17 ++++++++++++++++- hemx-js/tests/runtime.rs | 5 +++++ 7 files changed, 31 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e11548..71e21c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. - Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 -- JS runtime changes must preserve root-scoped lookup, fail-closed request handling, and tiny pending/failure recovery without selectors, VDOM, expressions, or per-node listeners. req: runtime/005 req: convention/007 +- JS runtime changes must preserve root-scoped lookup, fail-closed request handling, root-scoped error outlets, and tiny pending/failure recovery without selectors, VDOM, expressions, or per-node listeners. req: runtime/005 req: convention/007 - Host capability adapters must stay at the `hemx-host` boundary: they may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/002 - Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, or stored `EffectBatch` truth without a proven reusable contract. req: local/001 req: local/002 - Axum apps should serve and load the shared runtime through hemx-axum helpers such as `runtime_js_path()` and `runtime_js()`, not hard-coded `/hemx.js` URLs or app-owned cache-busting strings. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 7b676ab..72fd506 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -448,7 +448,7 @@ what a valid business email is. 004 Core runtime exposes a minimal version/fingerprint handshake only. Capability negotiation belongs to integration crates such as `hemx-wasm`, `hemx-sync`, and `hemx-transition`. ### req: runtime/005 -005 Failed hemx HTTP requests must fail closed: non-2xx responses are not applied as effects, pending state is restored, and the runtime emits an inspectable `hemx:error` event with status when available. [north_star] +005 Failed hemx HTTP requests must fail closed: non-2xx responses are not applied as effects, pending state is restored, root-scoped `data-hemx-error` outlets show a transport failure when present, and the runtime emits an inspectable `hemx:error` event with status when available. [north_star] --- diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs index 1938f82..47d8137 100644 --- a/examples/v0/src/main.rs +++ b/examples/v0/src/main.rs @@ -133,7 +133,7 @@ impl Hemplate for Todos { fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> { use std::fmt::Write as _; - buf.push_str("
\n
\n \n \n

\n
\n\n

"); + buf.push_str("

\n
\n \n \n

\n
\n\n \n\n

"); write!(buf, "{}", hemplate::HtmlEscape(&self.summary))?; buf.push_str("

\n

"); write!(buf, "{}", hemplate::HtmlEscape(&self.notice))?; @@ -627,6 +627,11 @@ mod tests { 2, "add and rename forms expose generated field-error targets" ); + assert_eq!( + document.select(&selector("[data-hemx-error]")).count(), + 1, + "the app exposes one root-scoped transport error outlet" + ); } // req: canonical_authoring/003 req: test/005 diff --git a/examples/v0/templates/todos.heml b/examples/v0/templates/todos.heml index a66ea91..cb5b730 100644 --- a/examples/v0/templates/todos.heml +++ b/examples/v0/templates/todos.heml @@ -4,6 +4,7 @@

+

{+ self.summary +}

{+ self.notice +}

    diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 8e2e09b..e70f7c0 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -1541,6 +1541,7 @@ fn known_hemx_attr(name: &str) -> bool { | "data-hemx-nav" | "data-hemx-boost" | "data-hemx-error-for" + | "data-hemx-error" | "data-hemx-island" ) } diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 1992436..89fac9a 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -194,6 +194,17 @@ return error; } + function showError(el, error) { + const root = rootOf(el) || document; + const message = error ? (error.status ? `Request failed (${error.status})` : "Request failed") : ""; + forEachElement(root, (outlet) => { + if (!outlet.hasAttribute("data-hemx-error")) return; + outlet.textContent = message; + if (message) outlet.removeAttribute("hidden"); + else outlet.setAttribute("hidden", ""); + }); + } + 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); @@ -224,6 +235,7 @@ const headers = { "X-HEMX-Partial": "1", "Accept": "application/hemx, text/html" }; if (body instanceof URLSearchParams) headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8"; pending.set(target, { abort, done }); + showError(target, null); showPending(target, true); try { const response = await fetch(requestUrl(form, data, method), { @@ -237,7 +249,10 @@ if (!response.ok) throw httpError(response); await applyResponse(response, rootOf(target)); } catch (error) { - if (error.name !== "AbortError") emit(rootOf(target), "hemx:error", { message: String(error), status: error.status || null }); + if (error.name !== "AbortError") { + showError(target, error); + emit(rootOf(target), "hemx:error", { message: String(error), status: error.status || null }); + } } finally { if (pending.get(target)?.abort === abort) { pending.delete(target); diff --git a/hemx-js/tests/runtime.rs b/hemx-js/tests/runtime.rs index da1ed18..3512932 100644 --- a/hemx-js/tests/runtime.rs +++ b/hemx-js/tests/runtime.rs @@ -23,6 +23,9 @@ fn runtime_reports_http_failures_without_applying_effects() { assert!(source.contains("function httpError(response)")); assert!(source.contains("if (!response.ok) throw httpError(response)")); + assert!(source.contains("showError(target, error)")); + assert!(source.contains("showError(target, null)")); + assert!(source.contains("Request failed (${error.status})")); assert!(source.contains("error.status = response.status")); assert!(source.contains( "emit(rootOf(target), \"hemx:error\", { message: String(error), status: error.status || null })" @@ -129,6 +132,8 @@ fn runtime_toggles_pending_conventions_around_requests() { assert!(source.contains("el.removeAttribute(\"aria-busy\")")); assert!(source.contains("toggleBusy(el, on)")); assert!(source.contains("function toggleIndicator(indicator, on)")); + assert!(source.contains("data-hemx-error")); + assert!(source.contains("function showError(el, error)")); assert!(source.contains("toggleIndicator(i, on)")); assert!(source.contains("indicator.hidden = state.hidden")); assert!(source.contains("el.hasAttribute(\"data-hemx-disable-while-pending\")"));