diff --git a/PLAN.md b/PLAN.md index b1a7e86..36fe61a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,17 +1,11 @@ # Current plan -## Slice HMX-002 — Native interaction and recovery +## Closed receipt — HMX-002 + +Result: Generated semantic anchors and forms use the canonical marker vocabulary, bounded request policy, accessible validation markup, enhanced requests, and native recovery. +Proof: Focused Redgate checks, workspace gates, request-boundary tests, navigation failure recovery, and fresh-context review passed; HMX-002 IDs have no findings. +Residual risk: Browser behavior is deterministically checked at generated-markup, runtime, and Axum boundaries rather than through an external browser harness. -Outcome: Generated semantic HTML submits forms and navigation through the closed effect path while preserving accessibility and ordinary browser recovery. -Delta: html/001–007, runtime/003–004, axum/003–004. -Path: generated marker and native anchor/form -> request policy and Axum boundary -> rendered validation or navigation response -> accessible browser result or native fallback. -Build: Align generated marker vocabulary, native-event defaults, bounded request policies, form encoding, validation markup, partial navigation metadata, and full-navigation recovery. -Risk: Enhancement can suppress native input, accessibility, or fallback behavior and leave users unable to submit, navigate, or recover. -Checks: Generated-markup assertions for every marker and policy value; browser scenarios with enhancement enabled and unavailable; form encoding and body-limit integration tests; validation accessibility assertions; partial/full navigation recovery scenarios; `redgate check` for the slice IDs. -Non-goals: SSE, WebSocket, timers, reveal behavior, or island-owned local state. -Residual risk: Optional adapter and island lifecycle remains open. -State: Ready -Blocked by: none ## Slice HMX-003 — Direct adapters and explicit islands diff --git a/hemx-axum/src/lib.rs b/hemx-axum/src/lib.rs index 23842ac..ff0f39f 100644 --- a/hemx-axum/src/lib.rs +++ b/hemx-axum/src/lib.rs @@ -1359,7 +1359,7 @@ impl IntoResponse for PageResponse { } fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -> String { - if html.contains("data-hemx-fp=") { + if html.contains("data-hemx-build=") { return html; } let Some(root_attr) = html.find("data-hemx-root") else { @@ -1372,7 +1372,10 @@ fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) - return html; }; let insert_at = tag_start + tag_end; - html.insert_str(insert_at, &format!(" data-hemx-fp=\"{}\"", fingerprint.0)); + html.insert_str( + insert_at, + &format!(" data-hemx-build=\"{}\"", fingerprint.0), + ); html } @@ -1582,14 +1585,14 @@ mod tests { .select(&selector("main[data-hemx-root]")) .next() .expect("root element is rendered"); - assert_eq!(root.value().attr("data-hemx-fp"), Some("99")); + assert_eq!(root.value().attr("data-hemx-build"), Some("99")); assert_eq!(root.text().collect::(), "Docs"); } #[test] fn existing_root_fingerprint_is_preserved() { let html = html_with_root_fingerprint( - "
Docs
".into(), + "
Docs
".into(), BuildFingerprint(99), ); @@ -1598,10 +1601,10 @@ mod tests { .select(&selector("main[data-hemx-root]")) .next() .expect("root element is rendered"); - assert_eq!(root.value().attr("data-hemx-fp"), Some("1")); + assert_eq!(root.value().attr("data-hemx-build"), Some("1")); assert_eq!(root.text().collect::(), "Docs"); - assert_eq!(html.matches("data-hemx-fp=").count(), 1); - assert!(!html.contains("data-hemx-fp=\"99\"")); + assert_eq!(html.matches("data-hemx-build=").count(), 1); + assert!(!html.contains("data-hemx-build=\"99\"")); } #[test] diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index a591dce..10fa8c2 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -1029,17 +1029,17 @@ fn __hemx_lower_html(html: &str, table: &[(&str, &str, u32)]) -> ::std::string:: let mut out = html.to_owned(); for (attr, name, id) in table { let runtime_attr = match *attr { - "data-hemx-slot" => "data-sid", - "data-hemx-handle" => "data-hid", - "data-hemx-form" => "data-fid", + "data-hemx-slot" => "data-hemx-resource", + "data-hemx-handle" => "data-hemx-action", + "data-hemx-form" => "data-hemx-resource", "data-hemx-revealed" => "data-hemx-revealed", - "data-hemx-atom" => "data-aid", + "data-hemx-atom" => "data-hemx-resource", _ => continue, }; out = __hemx_replace_attr(out, attr, name, runtime_attr, *id); } - out = __hemx_lower_static_attr(out, "data-hemx-key", "data-key"); - out = __hemx_lower_static_attr(out, "h-key", "data-key"); + out = __hemx_lower_static_attr(out, "data-hemx-key", "data-hemx-key"); + out = __hemx_lower_static_attr(out, "h-key", "data-hemx-key"); __hemx_inject_handle_inputs(out) } @@ -1075,7 +1075,7 @@ fn __hemx_inject_handle_inputs(html: ::std::string::String) -> ::std::string::St out.push_str(opening); rest = &rest[open_end + 1..]; - let Some(handle_id) = __hemx_attr(opening, "data-hid") else { + let Some(handle_id) = __hemx_attr(opening, "data-hemx-action") else { continue; }; let form_body_end = rest.find("").unwrap_or(rest.len()); @@ -1536,7 +1536,6 @@ fn known_hemx_attr(name: &str) -> bool { | "data-hemx-indicator" | "data-hemx-confirm" | "data-hemx-debounce" - | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every" | "data-hemx-interval" @@ -1622,7 +1621,7 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i "expected a non-negative number of viewports", )); } - "data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" | "data-hemx-every" + "data-hemx-debounce" | "data-hemx-throttle" | "data-hemx-every" | "data-hemx-interval" if !valid_duration(value) => { @@ -2025,7 +2024,7 @@ fn unkeyed_generated_target_diagnostic( "a stable template h-key on h-for=\"{pattern} in {expr}\" so generated keyed helpers such as ui::{name}.replace(row) can target this partial" ), repair: format!( - "add h-key=\"{pattern}.id\" to that h-for; dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets" + "add h-key=\"{pattern}.id\" to that h-for; dynamic +data-hemx-key on the child is rendered HTML, not the template fact hemx uses for generated targets" ), } } @@ -2655,12 +2654,12 @@ mod tests { let canonical_syms = resources.syms(); assert_eq!( stable_id("generated-rs", &canonical_generated), - 262_445_078, + 3_787_638_288, "canonical generated Rust changed" ); assert_eq!( stable_id("generated-rs-global", &canonical_globals), - 3_211_924_671, + 1_112_484_789, "canonical global-export Rust changed" ); assert_eq!( @@ -3036,8 +3035,9 @@ mod tests { assert!(generated.contains("pub fn replace(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect")); assert!(generated.contains("data-hemx-slot")); assert!(generated.contains("data-hemx-form")); - assert!(generated.contains("data-sid")); - assert!(generated.contains("data-fid")); + assert!(generated.contains("data-hemx-resource")); + assert!(!generated.contains("data-sid")); + assert!(!generated.contains("data-fid")); assert!(generated.contains("__hemx_inject_handle_inputs")); let syms = std::fs::read_to_string(out.join("hemx.syms")).unwrap(); @@ -3224,7 +3224,7 @@ mod tests { std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todos.heml"), - r#"
"#, + r#"
"#, ) .unwrap(); @@ -3465,6 +3465,8 @@ mod hemx {{ impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} pub struct Effect; #[derive(Clone, Copy)] pub struct ResourceId; + #[derive(Clone, Copy)] pub struct EventName(&'static str); + impl EventName {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} }} pub trait IntoEffect {{}} impl IntoEffect for Effect {{}} pub trait GeneratedTarget {{ fn __hemx_resource_id(self) -> ResourceId; }} @@ -3722,7 +3724,9 @@ fn main() {{ .contains("h-for=\"todo in &self.todos\" without h-key")); assert!(diagnostic.expected.contains("ui::todo_row.replace(row)")); assert!(diagnostic.repair.contains("h-key=\"todo.id\"")); - assert!(diagnostic.repair.contains("dynamic +data-key on the child")); + assert!(diagnostic + .repair + .contains("dynamic +data-hemx-key on the child")); assert!(diagnostic.to_string().contains("templates/todo.heml")); } @@ -3737,7 +3741,7 @@ fn main() {{ ), ( "slot_data_key", - r#""#, + r#""#, "data-hemx-slot=\"todo_row\"", "ui::todo_row", ), @@ -3778,7 +3782,7 @@ fn main() {{ assert!(err.contains("h-key=\"todo.id\"")); assert!(err.contains("generated keyed helpers")); assert!(err.contains(helper)); - assert!(err.contains("dynamic +data-key on the child")); + assert!(err.contains("dynamic +data-hemx-key on the child")); let _ = std::fs::remove_dir_all(&base); } @@ -3985,12 +3989,6 @@ fn main() {{ "data-hemx-ws", "non-empty", ), - ( - "delay-empty", - r#""#, - "data-hemx-delay", - "milliseconds", - ), ( "revealed-ahead-negative", r#"
"#, @@ -4003,12 +4001,6 @@ fn main() {{ "data-hemx-throttle", "milliseconds", ), - ( - "duration-suffix-only", - r#""#, - "data-hemx-delay", - "milliseconds", - ), ( "policy-duplicate-anchor", r#""#, @@ -4021,12 +4013,6 @@ fn main() {{ "data-hemx-debounce", "250ms", ), - ( - "delay", - r#""#, - "data-hemx-delay", - "250ms", - ), ( "every", r#""#, @@ -4103,8 +4089,8 @@ fn main() {{ "data-hemx-confirm" => "expected a non-empty confirmation message", "data-hemx-sse" => "expected a non-empty same-origin SSE URL", "data-hemx-ws" => "expected a non-empty same-origin WebSocket URL", - "data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle" - | "data-hemx-every" | "data-hemx-interval" => { + "data-hemx-debounce" | "data-hemx-throttle" | "data-hemx-every" + | "data-hemx-interval" => { "expected milliseconds like `250`/`250ms` or seconds like `1s`" } _ => panic!("missing expectation for {attr}"), @@ -4170,7 +4156,7 @@ fn main() {{ let valid_source = r#"
- +
"#; @@ -4282,7 +4268,7 @@ fn main() {{ } #[test] - fn generated_lowering_injects_progressive_form_handle_and_key() { + fn generated_lowering_preserves_native_interaction_contract() { let base = test_dir("hemx-build-lowering-test"); let templates = base.join("templates"); let out = base.join("out"); @@ -4290,7 +4276,7 @@ fn main() {{ std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), - r#"
  • "#, + r#"
    Docs
    Required
  • "#, ) .unwrap(); @@ -4305,6 +4291,8 @@ mod hemx {{ impl BuildFingerprint {{ pub const fn from_parts(_: &[u32]) -> Self {{ Self }} }} pub struct Effect; #[derive(Clone, Copy)] pub struct ResourceId; + #[derive(Clone, Copy)] pub struct EventName(&'static str); + impl EventName {{ pub const fn new(name: &'static str) -> Self {{ Self(name) }} }} pub trait IntoEffect {{}} impl IntoEffect for Effect {{}} pub trait GeneratedTarget {{ fn __hemx_resource_id(self) -> ResourceId; }} @@ -4337,10 +4325,21 @@ mod hemplate {{ }} {generated} fn main() {{ - let html = todo::lower_html(r#"
  • "#); - assert!(html.contains(r#"data-hid="#)); + let html = todo::lower_html(r#"
    Docs
    Required
  • "#); + assert!(html.contains(r#"data-hemx-root"#)); + assert!(html.contains(r#"data-hemx-action="#)); + assert!(html.contains(r#"data-hemx-resource="#)); + assert!(html.contains(r#"data-hemx-key="7""#)); + assert!(html.contains(r#"data-hemx-on="click""#)); + assert!(html.contains(r#""#)); + assert!(html.contains(r#"action="/todos" method="post""#)); + assert!(html.contains(r#"action="/search" method="get""#)); + assert!(html.contains(r#"aria-invalid="true""#)); + assert!(html.contains(r#"aria-describedby="title-error""#)); + assert!(html.contains(r#"id="title-error">Required"#)); assert!(html.contains(r#"name="__h""#)); - assert!(html.contains(r#"data-key="7""#)); + assert!(!html.contains("data-hid")); + assert!(!html.contains("data-sid")); assert!(!html.contains("h-key")); }} "###, diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index eddd455..fe4d5d1 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -1,9 +1,9 @@ (() => { const ROOT = "data-hemx-root"; - const HID = "data-hid"; - const SID = "data-sid"; + const ACTION = "data-hemx-action"; + const RESOURCE = "data-hemx-resource"; const runtimeAbiVersion = 1; - const FINGERPRINT = "data-hemx-fp"; + const BUILD = "data-hemx-build"; const STATE = "data-hemx-st"; const pending = new WeakMap(); const queues = new WeakMap(); @@ -54,8 +54,8 @@ function formHandleId(form) { if (!form) return null; - const holder = form.hasAttribute(HID) ? form : firstElement(form, (el) => el.hasAttribute(HID)); - const raw = holder && holder.getAttribute(HID); + const holder = form.hasAttribute(ACTION) ? form : firstElement(form, (el) => el.hasAttribute(ACTION)); + const raw = holder && holder.getAttribute(ACTION); return raw && /^\d+$/.test(raw) ? raw : null; } @@ -68,7 +68,7 @@ } function handleId(el) { - const raw = el && el.getAttribute(HID); + const raw = el && el.getAttribute(ACTION); return raw && /^\d+$/.test(raw) ? raw : null; } @@ -173,7 +173,7 @@ const dragKey = eventName === "drop" && dragKeys.get(rootOf(el)); if (dragKey && !data.has("work_id")) data.set("work_id", dragKey); for (const { name, value } of Array.from(el.attributes || [])) { - if (name.startsWith("data-") && !name.startsWith("data-hemx-") && name !== HID && name !== SID) { + if (name.startsWith("data-") && !name.startsWith("data-hemx-") && name !== ACTION && name !== RESOURCE) { data.set(name.slice(5).replace(/-/g, "_"), value); } } @@ -356,17 +356,25 @@ async function navigateUrl(href, root, mode = "replace") { if (mode === "push") savePage(root); - const response = await fetch(href, { - headers: { "X-HEMX-Partial": "1", "Accept": "text/html" }, - credentials: "same-origin", - }); - if (!await applyResponse(response, root)) { - if (mode === "none") location.reload(); - else location.href = href; - return; + try { + const response = await fetch(href, { + headers: { "X-HEMX-Partial": "1", "Accept": "text/html" }, + credentials: "same-origin", + }); + if (!response.ok || !await applyResponse(response, root)) { + fallbackNavigation(href, mode); + return; + } + if (mode === "push") history.pushState({ hemx: true }, "", href); + else if (mode === "replace") history.replaceState({ hemx: true }, "", href); + } catch (_) { + fallbackNavigation(href, mode); } - if (mode === "push") history.pushState({ hemx: true }, "", href); - else if (mode === "replace") history.replaceState({ hemx: true }, "", href); + } + + function fallbackNavigation(href, mode) { + if (mode === "none") location.reload(); + else location.href = href; } async function applyResponse(response, root) { @@ -389,7 +397,7 @@ function compatibleFingerprint(response, root) { const received = response.headers.get("x-hemx-fingerprint"); - const expected = root && root.getAttribute(FINGERPRINT); + const expected = root && root.getAttribute(BUILD); return !received || !expected || received === expected; } @@ -404,7 +412,7 @@ } throw error; } - const expected = root && root.getAttribute(FINGERPRINT); + const expected = root && root.getAttribute(BUILD); if (expected && String(batch.fingerprint) !== expected) { location.reload(); return; @@ -495,11 +503,11 @@ } function generatedResource(el, id) { - return attrEquals(el, "data-sid", id) || attrEquals(el, "data-slot-id", id); + return attrEquals(el, RESOURCE, id); } function generatedForm(el, id) { - return attrEquals(el, "data-fid", id) || attrEquals(el, "data-form-id", id); + return generatedResource(el, id); } function generatedTarget(scope, id) { @@ -550,7 +558,7 @@ } function keyedTarget(scope, id, key) { - return firstElement(scope, (el) => attrEquals(el, "data-key", key) && withinGeneratedResource(el, scope, id)); + return firstElement(scope, (el) => attrEquals(el, "data-hemx-key", key) && withinGeneratedResource(el, scope, id)); } function fieldTarget(scope, id, field) { @@ -585,8 +593,8 @@ else template.textContent = payload.value; const nodes = Array.from(template.content.childNodes); const firstElement = nodes.find((node) => node.nodeType === 1); - if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key); - if (firstElement && resourceId != null && !firstElement.hasAttribute("data-sid")) firstElement.setAttribute("data-sid", resourceId); + if (firstElement && key != null && !firstElement.hasAttribute("data-hemx-key")) firstElement.setAttribute("data-hemx-key", key); + if (firstElement && resourceId != null && !firstElement.hasAttribute("data-hemx-resource")) firstElement.setAttribute("data-hemx-resource", resourceId); return nodes; } @@ -619,7 +627,7 @@ function replaceLoweredSlots(scope, doc) { let changed = false; forEachElement(doc.body || doc, (source) => { - const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id"); + const id = source.getAttribute(RESOURCE); if (!id) return; const target = generatedTarget(scope, id); if (target) { @@ -674,15 +682,15 @@ ["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => { root.addEventListener(name, (event) => { if (name === "dragstart") { - const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key")); + const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-key")); if (item) { - dragKeys.set(root, item.getAttribute("data-key")); - if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-key")); + dragKeys.set(root, item.getAttribute("data-hemx-key")); + if (event.dataTransfer) event.dataTransfer.setData("text/plain", item.getAttribute("data-hemx-key")); } return; } if (name === "dragover") { - const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(HID) && el.getAttribute("data-hemx-on") === "drop"); + const drop = closestInRoot(event.target, root, (el) => el.hasAttribute(ACTION) && el.getAttribute("data-hemx-on") === "drop"); if (drop) event.preventDefault(); return; } @@ -696,7 +704,7 @@ return; } if (name === "click") { - const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID)); + const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(ACTION)); if (direct && handlesEvent(direct, "click")) { event.preventDefault(); schedule(direct, name); @@ -717,7 +725,7 @@ } } let el = closestInRoot(event.target, root, (node) => - node.hasAttribute(HID) || + node.hasAttribute(ACTION) || (node.tagName === "FORM" && (pageFormHistoryMode(event.submitter || node, node) || boostRoot(node))) ); if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && (formHandleId(event.target) || pageFormHistoryMode(event.submitter || event.target, event.target))) el = event.target; @@ -732,13 +740,10 @@ function schedule(el, eventName, source = el) { const debounce = duration(el.getAttribute("data-hemx-debounce")); - const delay = duration(el.getAttribute("data-hemx-delay")); const throttle = duration(el.getAttribute("data-hemx-throttle")); if (debounce) { clearTimeout(timers.get(el)); timers.set(el, setTimeout(() => send(el, eventName, source), debounce)); - } else if (delay) { - setTimeout(() => send(el, eventName, source), delay); } else if (throttle) { if (timers.get(el)) return; send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle)); diff --git a/hemx-js/tests/runtime.rs b/hemx-js/tests/runtime.rs index 47278cb..2fa68cb 100644 --- a/hemx-js/tests/runtime.rs +++ b/hemx-js/tests/runtime.rs @@ -75,6 +75,20 @@ fn runtime_preserves_multipart_file_upload_fallback_shape() { assert!(source.contains("if (body instanceof URLSearchParams) headers[\"Content-Type\"] = \"application/x-www-form-urlencoded;charset=UTF-8\"")); } +#[test] +fn runtime_preserves_native_form_controls_and_validation() { + let source = hemx_js::RUNTIME_JS; + + assert!(source.contains("if (form.reportValidity && !form.reportValidity()) return;")); + assert!(source.contains( + "return source && source !== form ? new FormData(form, source) : new FormData(form);" + )); + assert!(source.contains("event.submitter || node")); + assert!(source.contains("multipart/form-data")); + assert!(!source.contains("compositionstart")); + assert!(!source.contains("compositionend")); +} + #[test] fn runtime_uses_root_scoped_walks_not_dom_selector_apis() { let source = hemx_js::RUNTIME_JS; @@ -98,7 +112,7 @@ fn runtime_targets_generated_resources_not_response_selectors() { assert!(source.contains("return generatedTarget(scope, ref.resource.id)")); assert!(source.contains("function firstElement(scope, predicate)")); assert!(source.contains("function generatedResource(el, id)")); - assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))")); + assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-hemx-key\", key) && withinGeneratedResource(el, scope, id))")); assert!(source.contains("if (node.hasAttribute && node.hasAttribute(ROOT)) continue")); assert!(source.contains("if (!applyOp(scope, op)) return")); assert!(!source.contains("canApplyOp")); @@ -129,11 +143,10 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() { } #[test] -fn runtime_supports_tiny_delay_and_revealed_scheduling() { +fn runtime_supports_revealed_scheduling() { let source = hemx_js::RUNTIME_JS; - assert!(source.contains("data-hemx-delay")); - assert!(source.contains("setTimeout(() => send(el, eventName, source), delay)")); + assert!(!source.contains("data-hemx-delay")); assert!(source.contains("const revealObservers = new WeakMap()")); assert!(source.contains("const revealed = new WeakSet()")); assert!(source.contains("data-hemx-revealed")); @@ -238,7 +251,7 @@ fn runtime_clicking_submitter_schedules_form_submit() { ); assert!(!source.contains("document.getElementById(el.getAttribute(\"form\"))")); assert!(source.contains( - "const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))" + "const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(ACTION))" )); assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>")); assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")")); @@ -309,6 +322,7 @@ fn runtime_exposes_page_swap_hooks() { assert!(source.contains("x-hemx-fingerprint")); assert!(source.contains("hemx:missing-content-slot")); assert!(source.contains("else location.href = href")); + assert!(source.contains("if (!response.ok || !await applyResponse(response, root))")); } #[test] @@ -317,6 +331,7 @@ fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() { assert!(source.contains("if (mode === \"none\") location.reload();")); assert!(source.contains("if (root) navigateUrl(location.href, root, \"none\")")); + assert!(source.contains("catch (_) {\n fallbackNavigation(href, mode);")); } #[test] @@ -338,7 +353,7 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() { assert!(source.contains("function compatibleFingerprint(response, root)")); assert!(source.contains("const received = response.headers.get(\"x-hemx-fingerprint\")")); - assert!(source.contains("const expected = root && root.getAttribute(FINGERPRINT)")); + assert!(source.contains("const expected = root && root.getAttribute(BUILD)")); assert!(source.contains("if (!compatibleFingerprint(response, root))")); assert!(source.contains("location.reload()")); assert!(source.contains("if (expected && String(batch.fingerprint) !== expected)")); diff --git a/hemx-test/README.md b/hemx-test/README.md index c3d3a51..2039c4e 100644 --- a/hemx-test/README.md +++ b/hemx-test/README.md @@ -51,7 +51,7 @@ Complete server-rendered pages can be inspected directly: ```rust let page = hemx_test::inspect_html_document( - "
    Ready
    ", + "
    Ready
    ", ); page.assert_text("main#app", "Ready"); # use hemx_core::{GeneratedTarget, ResourceId, Slot}; diff --git a/hemx-test/src/html.rs b/hemx-test/src/html.rs index adb552d..08f3449 100644 --- a/hemx-test/src/html.rs +++ b/hemx-test/src/html.rs @@ -101,7 +101,7 @@ impl HtmlInspector { /// Select elements carrying the runtime marker for a typed handle. pub fn select_handle(&self, handle: Handle) -> HtmlSelection { - self.selection_or_panic(&attribute_selector("data-hid", &handle.to_string())) + self.selection_or_panic(&attribute_selector("data-hemx-action", &handle.to_string())) } /// Assert that rendered HTML contains a typed handle marker. @@ -299,10 +299,10 @@ impl Error for HtmlInspectionError {} fn resource_attribute(kind: ResourceKind) -> &'static str { match kind { - ResourceKind::Slot => "data-sid", - ResourceKind::Atom => "data-aid", - ResourceKind::Handle => "data-hid", - ResourceKind::Form => "data-fid", + ResourceKind::Slot => "data-hemx-resource", + ResourceKind::Atom => "data-hemx-resource", + ResourceKind::Handle => "data-hemx-action", + ResourceKind::Form => "data-hemx-resource", } } diff --git a/hemx-test/src/lib.rs b/hemx-test/src/lib.rs index 2ee24af..93342ea 100644 --- a/hemx-test/src/lib.rs +++ b/hemx-test/src/lib.rs @@ -403,7 +403,7 @@ impl EffectInspector { /// Assert that generated keyed-row metadata for a key is absent from payloads. pub fn payload_excludes_key(&self, key: impl ToString) -> bool { - self.payload_excludes(&format!("data-key=\"{}\"", key.to_string())) + self.payload_excludes(&format!("data-hemx-key=\"{}\"", key.to_string())) } /// Return HTML for a generated target containing text, without exposing raw payloads. diff --git a/hemx-test/tests/html.rs b/hemx-test/tests/html.rs index d919cb9..c2f4bd5 100644 --- a/hemx-test/tests/html.rs +++ b/hemx-test/tests/html.rs @@ -49,7 +49,7 @@ fn inspects_generated_targets_and_handles_without_raw_runtime_ids() { let target = TestTarget(ResourceKind::Slot, 42); let handle = hemx_core::Handle::<()>::new(7); let inspected = hemx_test::inspect_html_fragment( - r#"
    "#, + r#"
    "#, ); inspected.assert_target(target); @@ -100,7 +100,7 @@ fn inspects_generated_target_document_and_fragment_payloads() { Effect::Insert { target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)), position: InsertPosition::Last, - html: SafeHtml::trusted("
  • later
  • "), + html: SafeHtml::trusted("
  • later
  • "), }, "Insert", ), @@ -108,7 +108,7 @@ fn inspects_generated_target_document_and_fragment_payloads() { Effect::Insert { target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)), position: InsertPosition::First, - html: SafeHtml::trusted("
  • first
  • "), + html: SafeHtml::trusted("
  • first
  • "), }, "Insert", ), @@ -116,7 +116,7 @@ fn inspects_generated_target_document_and_fragment_payloads() { let fragment = hemx_test::inspect(effect) .target_html_fragment(TestTarget(ResourceKind::Slot, 2)) .unwrap(); - fragment.assert_count("li[data-key]", 1); + fragment.assert_count("li[data-hemx-key]", 1); assert!(fragment.origin().contains(operation)); } } diff --git a/hemx-test/tests/inspector.rs b/hemx-test/tests/inspector.rs index 3ba4dde..7bf4a37 100644 --- a/hemx-test/tests/inspector.rs +++ b/hemx-test/tests/inspector.rs @@ -136,7 +136,7 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() { Effect::Patch { target: keyed_ref.clone(), mode: PatchMode::Replace, - html: SafeHtml::trusted("
  • replacement
  • "), + html: SafeHtml::trusted("
  • replacement
  • "), }, Effect::Insert { target: ResourceRef::unscoped(resource), @@ -202,7 +202,7 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() { assert!(!inspected.payload_excludes_key("row-1")); assert_eq!( inspected.target_html_containing(expected, "replacement"), - Some("
  • replacement
  • ") + Some("
  • replacement
  • ") ); assert_eq!( inspected.target_html_containing(expected, "inserted"), diff --git a/tests/redgate_test.sh b/tests/redgate_test.sh index 765dabf..d3d2fe3 100755 --- a/tests/redgate_test.sh +++ b/tests/redgate_test.sh @@ -20,9 +20,13 @@ cargo test -p hemx-derive --test compile_fail generated_resource_references_fail cargo test -p hemx-axum --test response effect_response_is_wire_batch_with_fingerprint_header -- --exact # spec: axum/001 check cargo test -p hemx-axum --test response runtime_js_response_serves_embedded_runtime -- --exact # spec: axum/002 check cargo test -p hemx-axum --test response partial_page_response_sets_partial_and_title_headers -- --exact # spec: axum/003 check +cargo test -p hemx-js --test runtime runtime_popstate_failed_partials_reload_instead_of_stale_ui -- --exact # spec: axum/003 check cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check cargo test -p hemx-axum --test response effect_and_dispatch_responses_preserve_status_wire_and_diagnostics -- --exact # spec: axum/005 check cargo test -p hemx-js --test runtime runtime_exposes_debug_api_before_startup_side_effects -- --exact # spec: runtime/002 check +cargo test -p hemx-build generated_lowering_preserves_native_interaction_contract --lib # spec: html/001 check # spec: html/002 check # spec: html/003 check # spec: html/004 check +cargo test -p hemx-build static_attribute_value_validators_cover_every_boundary --lib # spec: html/006 check # spec: html/007 check +cargo test -p hemx-js --test runtime runtime_preserves_native_form_controls_and_validation -- --exact # spec: html/005 check cargo test -p hemx-js --test runtime runtime_posts_urlencoded_forms_by_default -- --exact # spec: runtime/003 check cargo test -p hemx-js --test runtime runtime_turns_page_get_forms_into_url_state_navigation -- --exact # spec: runtime/004 check cargo test -p hemx-js --test runtime runtime_targets_generated_resources_not_response_selectors -- --exact # spec: kernel/003 check # spec: runtime/001 check