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
This commit is contained in:
slhx agent
2026-06-22 22:26:33 +02:00
parent f1aa232c94
commit de6ecd469f
7 changed files with 31 additions and 4 deletions
+1 -1
View File
@@ -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 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. - 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 - 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 - 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 - 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. - 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.
+1 -1
View File
@@ -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`. 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 ### 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]
--- ---
+6 -1
View File
@@ -133,7 +133,7 @@ impl Hemplate for Todos {
fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> { fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> {
use std::fmt::Write as _; use std::fmt::Write as _;
buf.push_str("<section data-hemx-root=\"todos\">\n <form data-hemx-handle=\"add_todo\" data-hemx-form=\"new_todo\">\n <label>New todo\n <input name=\"title\" required=\"required\">\n </label>\n <button type=\"submit\">Add</button>\n <p data-hemx-error-for=\"title\"></p>\n </form>\n\n <p data-hemx-slot=\"summary\">"); buf.push_str("<section data-hemx-root=\"todos\">\n <form data-hemx-handle=\"add_todo\" data-hemx-form=\"new_todo\">\n <label>New todo\n <input name=\"title\" required=\"required\">\n </label>\n <button type=\"submit\">Add</button>\n <p data-hemx-error-for=\"title\"></p>\n </form>\n\n <p data-hemx-error role=\"status\" hidden=\"hidden\"></p>\n\n <p data-hemx-slot=\"summary\">");
write!(buf, "{}", hemplate::HtmlEscape(&self.summary))?; write!(buf, "{}", hemplate::HtmlEscape(&self.summary))?;
buf.push_str("</p>\n <p data-hemx-slot=\"notice\">"); buf.push_str("</p>\n <p data-hemx-slot=\"notice\">");
write!(buf, "{}", hemplate::HtmlEscape(&self.notice))?; write!(buf, "{}", hemplate::HtmlEscape(&self.notice))?;
@@ -627,6 +627,11 @@ mod tests {
2, 2,
"add and rename forms expose generated field-error targets" "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 // req: canonical_authoring/003 req: test/005
+1
View File
@@ -4,6 +4,7 @@
<button type="submit">Add</button> <button type="submit">Add</button>
<p data-hemx-error-for="title"></p> <p data-hemx-error-for="title"></p>
</form> </form>
<p data-hemx-error role="status" hidden="hidden"></p>
<p data-hemx-slot="summary">{+ self.summary +}</p> <p data-hemx-slot="summary">{+ self.summary +}</p>
<p data-hemx-slot="notice">{+ self.notice +}</p> <p data-hemx-slot="notice">{+ self.notice +}</p>
<ul data-hemx-slot="todo_row"> <ul data-hemx-slot="todo_row">
+1
View File
@@ -1541,6 +1541,7 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-nav" | "data-hemx-nav"
| "data-hemx-boost" | "data-hemx-boost"
| "data-hemx-error-for" | "data-hemx-error-for"
| "data-hemx-error"
| "data-hemx-island" | "data-hemx-island"
) )
} }
+16 -1
View File
@@ -194,6 +194,17 @@
return error; 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) { async function send(el, eventName, source = el) {
if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return; if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return;
const { form, data, multipart } = formDataFor(el, eventName, source); const { form, data, multipart } = formDataFor(el, eventName, source);
@@ -224,6 +235,7 @@
const headers = { "X-HEMX-Partial": "1", "Accept": "application/hemx, text/html" }; 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"; if (body instanceof URLSearchParams) headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
pending.set(target, { abort, done }); pending.set(target, { abort, done });
showError(target, null);
showPending(target, true); showPending(target, true);
try { try {
const response = await fetch(requestUrl(form, data, method), { const response = await fetch(requestUrl(form, data, method), {
@@ -237,7 +249,10 @@
if (!response.ok) throw httpError(response); if (!response.ok) throw httpError(response);
await applyResponse(response, rootOf(target)); await applyResponse(response, rootOf(target));
} catch (error) { } 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 { } finally {
if (pending.get(target)?.abort === abort) { if (pending.get(target)?.abort === abort) {
pending.delete(target); pending.delete(target);
+5
View File
@@ -23,6 +23,9 @@ fn runtime_reports_http_failures_without_applying_effects() {
assert!(source.contains("function httpError(response)")); assert!(source.contains("function httpError(response)"));
assert!(source.contains("if (!response.ok) throw 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("error.status = response.status"));
assert!(source.contains( assert!(source.contains(
"emit(rootOf(target), \"hemx:error\", { message: String(error), status: error.status || null })" "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("el.removeAttribute(\"aria-busy\")"));
assert!(source.contains("toggleBusy(el, on)")); assert!(source.contains("toggleBusy(el, on)"));
assert!(source.contains("function toggleIndicator(indicator, 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("toggleIndicator(i, on)"));
assert!(source.contains("indicator.hidden = state.hidden")); assert!(source.contains("indicator.hidden = state.hidden"));
assert!(source.contains("el.hasAttribute(\"data-hemx-disable-while-pending\")")); assert!(source.contains("el.hasAttribute(\"data-hemx-disable-while-pending\")"));