diff --git a/AGENTS.md b/AGENTS.md index 1679023..2e11548 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 and avoid selectors, VDOM, expressions, and per-node listeners. +- 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 - 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 fbef867..7b676ab 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -447,6 +447,9 @@ what a valid business email is. ### req: runtime/004 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] + --- ## failure @@ -983,7 +986,7 @@ async fn delete(app: State, todo_id: TodoId) -> impl IntoEffect 006 Request concurrency policy (`latest`, `queue`, `drop`, `parallel`) may be declared per handle with `data-hemx-policy`. Default for debounced/input handlers is `latest`; default for form submit is `drop` while pending. Stale EffectBatches from superseded requests must not be applied. ### req: convention/007 -007 Pending indicators are cosmetic only. The runtime toggles pending classes, indicator visibility, and disabled controls around request/effect execution; handler semantics are unchanged. +007 Pending indicators are cosmetic only. The runtime toggles pending classes, `aria-busy`, indicator visibility, and disabled controls around request/effect execution; handler semantics are unchanged. ### req: convention/008 008 `data-hemx-disable-while-pending` disables the triggering form controls or button while the request is active and restores them afterward. diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 4391c01..1992436 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -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); diff --git a/hemx-js/tests/runtime.rs b/hemx-js/tests/runtime.rs index b97aff6..da1ed18 100644 --- a/hemx-js/tests/runtime.rs +++ b/hemx-js/tests/runtime.rs @@ -16,6 +16,19 @@ fn runtime_exposes_debug_api_before_startup_side_effects() { assert!(source.contains("emit(root, \"hemx:bind-error\", String(error));")); } +#[test] +fn runtime_reports_http_failures_without_applying_effects() { + // req: runtime/005 + let source = hemx_js::RUNTIME_JS; + + assert!(source.contains("function httpError(response)")); + assert!(source.contains("if (!response.ok) throw httpError(response)")); + assert!(source.contains("error.status = response.status")); + assert!(source.contains( + "emit(rootOf(target), \"hemx:error\", { message: String(error), status: error.status || null })" + )); +} + #[test] fn runtime_posts_urlencoded_forms_by_default() { let source = hemx_js::RUNTIME_JS; @@ -110,6 +123,11 @@ fn runtime_toggles_pending_conventions_around_requests() { assert!(source.contains("hadClass: el.classList.contains(klass)")); assert!(source.contains("if (state.hadClass) el.classList.add(state.className)")); assert!(source.contains("const indicatorStates = new WeakMap()")); + assert!(source.contains("const busyStates = new WeakMap()")); + assert!(source.contains("function toggleBusy(el, on)")); + assert!(source.contains("el.setAttribute(\"aria-busy\", \"true\")")); + assert!(source.contains("el.removeAttribute(\"aria-busy\")")); + assert!(source.contains("toggleBusy(el, on)")); assert!(source.contains("function toggleIndicator(indicator, on)")); assert!(source.contains("toggleIndicator(i, on)")); assert!(source.contains("indicator.hidden = state.hidden"));