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:
@@ -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
@@ -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]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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\")"));
|
||||||
|
|||||||
Reference in New Issue
Block a user