feat(runtime): add URL-state GET navigation

This commit is contained in:
slhx agent
2026-06-29 18:21:00 +02:00
parent 9dd8e4a944
commit d0e7022c86
10 changed files with 185 additions and 97 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata and postcard/form-encoded envelopes, not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: view/001 req: view/002 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework, and enhanced links preserve real anchors/history semantics. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
+19
View File
@@ -0,0 +1,19 @@
# hemx plan
This is a small work surface for explicitly delegated hemx follow-up. `REQUIREMENTS.md` remains the requirement authority; this file only records execution intent.
## URL-as-state page navigation primitive
User value: server-first apps can build browse/search/filter pages whose URL is the durable state, so reload, bookmarks, sharing, copy/paste, and back/forward work without app-owned client state or search frameworks.
State: Done
Build:
- Document the URL-as-state contract for enhanced GET forms and page swaps: native successful controls serialize into the URL; committed submits push history; high-frequency filter typing may replace history; normal reload/no-JS behavior remains native. req: page_swap/009
- Convert or add one html_examples browse/search example that uses a plain GET form plus generated page/target helpers to prove query-string state round-trips through server-rendered page state. req: page_swap/009 req: htmx_equivalents/005
- Add focused runtime/browser smoke that changes a GET query, verifies the URL, reloads or uses back/forward, and observes the same rendered state without hidden client state. req: page_swap/005 req: page_swap/009
- Keep the primitive subtractive: refuse an omnisearch framework, router, selector include model, client state graph, or domain search API in hemx core; use generated forms, page swap, and `Navigate` only. req: page_swap/003 req: page_swap/009 req: page_swap/010 req: htmx_equivalents/003
Blocked by: none.
Proof: `cargo run -p hemx-xtask -- html-examples-smoke`, focused runtime/page-swap tests, `redgate lint`, and `redgate health --strict` pass with an example showing URL state survives reload/back/forward.
+6
View File
@@ -225,6 +225,12 @@ client app state framework.
### req: page_swap/008
0 008 Boosted links behave like `data-hemx-nav`; boosted forms behave like hemx form submissions. External links, downloads, new-tab links, and modified-clicks preserve native browser behavior. [north_star]
### req: page_swap/009
0 009 Enhanced GET navigation treats the URL as shareable page state: native successful GET controls serialize into the request URL, history updates by intent, and reload/bookmark/back/forward reconstruct the same view. [north_star]
### req: page_swap/010
0 010 URL-as-state support remains a page/navigation convention, not a router, omnisearch framework, selector include system, or client state graph. [north_star]
---
## htmx_equivalents
+8 -1
View File
@@ -13,6 +13,13 @@ cargo run -p hemx-html-examples
Open <http://127.0.0.1:3029>.
The active-search example is URL state rather than an interaction handle: its
GET form serializes the visible `q` control into the page URL, live input uses
`data-hemx-history="replace"`, and the explicit submit button uses
`data-hemx-history="push"`. Reload, bookmark, and browser back/forward therefore
ask the same server route to re-render the filtered gallery instead of restoring
client-owned search state. req: page_swap/009 req: page_swap/010
## Pattern matrix
Names match the htmx example URL slug exactly, e.g. `modal-custom` from
@@ -38,7 +45,7 @@ Status legend:
| `lazy-load` | implemented | `data-hemx-revealed` dispatches a generated form once when visible; the server swaps a generated lazy panel. | `gallery.heml`, `gallery_handlers::lazy_load`, `LazyPanel` |
| `inline-validation` | implemented | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` |
| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `infinite_row` |
| `active-search` | implemented | The search form posts a query; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` |
| `active-search` | implemented | The search form uses GET URL state; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` |
| `progress-bar` | implemented | The Tick progress button advances server-owned progress and replaces a generated progress partial with visible percentage text. | `gallery_handlers::tick_progress`, `ProgressMeter` |
| `value-select` | implemented | The first select posts a generated form; the server derives and replaces generated option rows for the second select. | `gallery_handlers::choose_category`, `ValueOption` |
| `animations` | integration-owned | CSS transitions are presentation policy around generated replacements; hemx should only preserve stable DOM boundaries. | Use keyed partials and app CSS; no core animation framework. |
+77 -86
View File
@@ -1,5 +1,6 @@
use axum::body::Body;
use axum::extract::State;
use axum::http::Uri;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
@@ -27,7 +28,6 @@ struct GalleryState {
category: Mutex<String>,
value: Mutex<String>,
reset_status: Mutex<String>,
query: Mutex<String>,
}
#[derive(Clone)]
@@ -125,11 +125,6 @@ struct ResetMessage {
message: String,
}
#[hemx::form("search")]
struct SearchInput {
query: String,
}
#[derive(Hemplate)]
struct AppShell {
runtime_src: &'static str,
@@ -287,14 +282,18 @@ impl GalleryState {
category: Mutex::new("letters".into()),
value: Mutex::new("alpha".into()),
reset_status: Mutex::new("No message sent yet".into()),
query: Mutex::new(String::new()),
}
}
}
async fn home(State(state): State<Arc<GalleryState>>, request: PageRequest) -> impl IntoResponse {
async fn home(
State(state): State<Arc<GalleryState>>,
uri: Uri,
request: PageRequest,
) -> impl IntoResponse {
let query = query_param(uri.query(), "q");
request
.page_html(ui::page(&gallery_view(&state)), shell)
.page_html(ui::page(&gallery_view(&state, &query)), shell)
.title("hemx HTML examples")
.fingerprint(ui::BUILD_FINGERPRINT)
}
@@ -329,7 +328,7 @@ fn shell(body: Html) -> Html {
})
}
fn gallery_view(state: &GalleryState) -> Gallery {
fn gallery_view(state: &GalleryState, query: &str) -> Gallery {
let contacts = state
.contacts
.lock()
@@ -355,7 +354,7 @@ fn gallery_view(state: &GalleryState) -> Gallery {
let category = state.category.lock().unwrap().clone();
let value = state.value.lock().unwrap().clone();
let reset_status = state.reset_status.lock().unwrap().clone();
let query = state.query.lock().unwrap().clone();
let query = query.trim().to_owned();
let search_results = search_results_for(&query);
Gallery {
contacts,
@@ -458,6 +457,43 @@ fn search_results_for(query: &str) -> Vec<SearchResult> {
.collect()
}
fn query_param(query: Option<&str>, name: &str) -> String {
query
.unwrap_or("")
.split('&')
.filter_map(|pair| pair.split_once('='))
.find_map(|(key, value)| (key == name).then(|| form_decode(value)))
.unwrap_or_default()
}
fn form_decode(value: &str) -> String {
let mut bytes = Vec::with_capacity(value.len());
let mut input = value.as_bytes().iter().copied();
while let Some(byte) = input.next() {
match byte {
b'+' => bytes.push(b' '),
b'%' => {
let high = input.next().and_then(hex_value);
let low = input.next().and_then(hex_value);
if let (Some(high), Some(low)) = (high, low) {
bytes.push((high << 4) | low);
}
}
byte => bytes.push(byte),
}
}
String::from_utf8_lossy(&bytes).into_owned()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[hemx::component("gallery")]
mod gallery_handlers {
use super::*;
@@ -586,50 +622,6 @@ mod gallery_handlers {
effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded)));
effects
}
#[hemx::handler]
async fn search(
State(state): State<Arc<GalleryState>>,
Form(input): Form<SearchInput>,
) -> impl IntoEffect {
let query = input.query.trim().to_owned();
let previous_query = {
let mut stored_query = state.query.lock().unwrap();
let previous_query = stored_query.clone();
*stored_query = query.clone();
previous_query
};
let previous_results = search_results_for(&previous_query);
let current_results = search_results_for(&query);
let previous_keys = previous_results
.iter()
.map(|result| result.id.to_string())
.collect::<std::collections::BTreeSet<_>>();
let current_keys = current_results
.iter()
.map(|result| result.id.to_string())
.collect::<std::collections::BTreeSet<_>>();
// Keep filtered keyed collections stable: remove filtered-out rows, replace retained
// rows, and append newly visible rows instead of clearing the whole list. req: list/006
let mut effects = previous_keys
.difference(&current_keys)
.cloned()
.map(|key| gallery::search_result.remove(key))
.collect::<Vec<_>>();
effects.extend(current_results.into_iter().map(|result| {
if previous_keys.contains(&result.id.to_string()) {
gallery::search_result.replace(result)
} else {
gallery::search_result.append(result)
}
}));
effects.push(gallery::search_status.set(if query.is_empty() {
"Showing all results".into()
} else {
format!("Results for {query}")
}));
effects
}
}
#[hemx::component("contact_card")]
@@ -731,18 +723,45 @@ mod tests {
#[test]
fn inline_validation_form_uses_input_event_for_revalidation() {
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state).render().expect("render gallery");
let html = gallery_view(&state, "").render().expect("render gallery");
assert!(
html.contains("data-hemx-on=\"input\""),
"inline validation form must listen on input events"
);
}
#[test]
fn active_search_is_reconstructed_from_url_query() {
// req: page_swap/009 req: page_swap/010
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state, &query_param(Some("q=ga"), "q"))
.render()
.expect("render gallery");
assert!(html.contains("name=\"q\" value=\"ga\""));
assert!(html.contains("Results for ga"));
assert!(html.contains("Gamma"));
assert!(!html.contains("Alpha"));
}
#[test]
fn active_search_form_marks_live_replace_and_submit_push() {
// req: page_swap/009
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state, "").render().expect("render gallery");
assert!(html.contains("method=\"get\""));
assert!(html.contains("data-hemx-history=\"replace\""));
assert!(html.contains("data-hemx-on=\"input\""));
assert!(html.contains("data-hemx-history=\"push\""));
assert!(!html.contains("data-hemx-handle=\"search\""));
assert!(!html.contains("data-hemx-form=\"search\""));
assert!(!html.contains("name=\"__h\""));
}
#[test]
fn revealed_forms_preserve_data_hemx_revealed_attribute() {
// req: convention/005
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state).render().expect("render gallery");
let html = gallery_view(&state, "").render().expect("render gallery");
assert!(
html.contains("data-hemx-revealed=\"true\""),
"lazy-load and infinite-scroll forms must preserve data-hemx-revealed attribute"
@@ -792,7 +811,7 @@ mod tests {
#[tokio::test]
async fn gallery_covers_core_html_patterns_with_generated_resources() {
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state).render().expect("render gallery");
let html = gallery_view(&state, "").render().expect("render gallery");
assert!(html.contains("data-hemx-root=\"gallery\""));
for slug in [
"click-to-edit",
@@ -952,34 +971,6 @@ mod tests {
);
assert!(load.payload_contains("Loaded row 6"));
let search = inspect_batch(
InteractionRequest::from(form(gallery::search, &[("query", "ga")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(search.removes_key(gallery::search_result, "1"));
assert!(search.removes_key(gallery::search_result, "2"));
assert!(search.removes_key(gallery::search_result, "4"));
assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma"));
let broader_search = inspect_batch(
InteractionRequest::from(form(gallery::search, &[("query", "a")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(broader_search.inserts_html_containing(gallery::search_result, "1", "Alpha"));
assert!(broader_search.inserts_html_containing(gallery::search_result, "2", "Beta"));
assert!(broader_search.replaces_keyed_html_containing(
gallery::search_result,
"3",
"Gamma"
));
assert!(broader_search.inserts_html_containing(gallery::search_result, "4", "Delta"));
let row_save = inspect_batch(
InteractionRequest::from(form(
editable_row::save_row,
@@ -116,9 +116,9 @@
<section id="active-search" data-htmx-example="active-search" aria-labelledby="search-heading">
<h2 id="search-heading">active-search</h2>
<form data-hemx-handle="search" data-hemx-form="search">
<label>Search <input name="query" +value="self.query"></label>
<button type="submit">Search</button>
<form method="get" action="/" data-hemx-history="replace" data-hemx-on="input" data-hemx-debounce="150ms">
<label>Search <input name="q" +value="self.query"></label>
<button type="submit" data-hemx-history="push">Search</button>
</form>
<p data-hemx-slot="search_status">{+ self.search_status +}</p>
<ul data-hemx-slot="search_result">
+15
View File
@@ -1597,6 +1597,7 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-disable-while-pending"
| "data-hemx-policy"
| "data-hemx-nav"
| "data-hemx-history"
| "data-hemx-boost"
| "data-hemx-error-for"
| "data-hemx-error"
@@ -1619,6 +1620,14 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
"expected one of `latest`, `queue`, `drop`, or `parallel`",
));
}
"data-hemx-history" if !matches!(value.trim(), "" | "push" | "replace") => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected `push`, `replace`, or empty for the default push behavior",
));
}
"data-hemx-on" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
@@ -2820,6 +2829,12 @@ fn main() {{
"data-hemx-interval",
"1s",
),
(
"history",
r#"<form method="get" action="/search" data-hemx-history="append"><input name="q"></form>"#,
"data-hemx-history",
"replace",
),
(
"event",
r#"<button data-hemx-handle="save" data-hemx-on="keydown">Save</button>"#,
+39 -4
View File
@@ -190,6 +190,36 @@
return url.href;
}
function pageRequestUrl(form, source) {
const url = new URL((form && form.action) || location.href, location.href);
url.search = urlEncoded(successfulFormData(form, source)).toString();
return url.href;
}
function successfulFormData(form, source) {
try {
return source && source !== form ? new FormData(form, source) : new FormData(form);
} catch (_) {
return new FormData(form);
}
}
function pageFormHistoryMode(source, form) {
return historyMode(source, null) || historyMode(form, null) || historyMode(boostRoot(form), null);
}
function boostRoot(el) {
return el && closestInRoot(el.parentElement, rootOf(el), (node) => node.hasAttribute("data-hemx-boost"));
}
function historyMode(el, fallback) {
if (!el) return fallback;
const value = el.getAttribute("data-hemx-history") || el.getAttribute("data-hemx-nav") || "";
if (value === "push" || value === "replace" || value === "none") return value;
if (el.hasAttribute("data-hemx-history") || el.hasAttribute("data-hemx-nav")) return fallback || "push";
return fallback;
}
function httpError(response) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
@@ -233,6 +263,7 @@
let finish;
const done = new Promise((resolve) => { finish = resolve; });
const method = String((form && form.getAttribute("method")) || "POST").toUpperCase();
const mode = method === "GET" && form && pageFormHistoryMode(source, form);
const body = method === "GET" || method === "HEAD" ? undefined : requestBody(data, multipart);
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";
@@ -240,6 +271,10 @@
showError(target, null);
showPending(target, true);
try {
if (mode) {
await navigateUrl(pageRequestUrl(form, source), rootOf(form) || rootOf(el), mode);
return;
}
const response = await fetch(requestUrl(form, data, method), {
method,
body,
@@ -629,7 +664,7 @@
);
if (name === "click" && nav && sameOriginNav(event, nav)) {
event.preventDefault();
navigate(nav);
navigate(nav, historyMode(nav, "push"));
return;
}
if (name === "click") {
@@ -646,7 +681,7 @@
(el.tagName === "INPUT" && el.getAttribute("type") === "submit")
);
const form = formOwner(submitter);
if (form && root.contains(form) && formHandleId(form)) {
if (form && root.contains(form) && (formHandleId(form) || pageFormHistoryMode(submitter, form))) {
if (form.reportValidity && !form.reportValidity()) return;
event.preventDefault();
schedule(form, "submit", submitter);
@@ -655,9 +690,9 @@
}
let el = closestInRoot(event.target, root, (node) =>
node.hasAttribute(HID) ||
(node.tagName === "FORM" && closestInRoot(node.parentElement, root, (parent) => parent.hasAttribute("data-hemx-boost")))
(node.tagName === "FORM" && (pageFormHistoryMode(event.submitter || node, node) || boostRoot(node)))
);
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && (formHandleId(event.target) || pageFormHistoryMode(event.submitter || event.target, event.target))) el = event.target;
if (!el || defaultEvent(el) !== name) return;
event.preventDefault();
schedule(el, name, event.submitter || el);
+15
View File
@@ -43,6 +43,21 @@ fn runtime_posts_urlencoded_forms_by_default() {
assert!(source.contains("application/x-www-form-urlencoded;charset=UTF-8"));
}
#[test]
fn runtime_turns_page_get_forms_into_url_state_navigation() {
// req: page_swap/009 req: page_swap/010
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function pageFormHistoryMode(source, form)"));
assert!(source.contains("const mode = method === \"GET\" && form && pageFormHistoryMode(source, form)"));
assert!(source.contains("if (mode)"));
assert!(source.contains("await navigateUrl(pageRequestUrl(form, source), rootOf(form) || rootOf(el), mode)"));
assert!(source.contains("function successfulFormData(form, source)"));
assert!(source.contains("new FormData(form, source)"));
assert!(source.contains("data-hemx-nav"));
assert!(source.contains("data-hemx-history"));
}
#[test]
fn runtime_preserves_multipart_file_upload_fallback_shape() {
// req: multipart/003
+2 -2
View File
@@ -238,7 +238,7 @@ fn cdp_assert(name: &str, script: &str) -> Result<(), ExitCode> {
const __hemxSmokeNavCount = performance.getEntriesByType("navigation").length;
window.__hemxSmokeNoReload = __hemxSmokeMarker;
const __hemxSmokeResult = await ({script});
if (location.href !== __hemxSmokeUrl) throw new Error("page navigated during smoke interaction");
if ("{name}" !== "active search" && location.href !== __hemxSmokeUrl) throw new Error("page navigated during smoke interaction");
if (window.__hemxSmokeNoReload !== __hemxSmokeMarker) throw new Error("page reloaded during smoke interaction");
if (performance.getEntriesByType("navigation").length !== __hemxSmokeNavCount) throw new Error("navigation entry changed during smoke interaction");
return __hemxSmokeResult;
@@ -264,7 +264,7 @@ fn cdp_assert(name: &str, script: &str) -> Result<(), ExitCode> {
const CLICK_TO_EDIT_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[0]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.name&&f.elements.name.value==="Ada Lovelace"); save.elements.name.value="Ada Byron"; save.elements.email.value="ada.byron@example.com"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!(text().includes("Ada Byron")&&!text().includes("Ada Lovelace ada@example.com"))) throw new Error("click-to-edit did not save"); return true;})()"#;
const EDIT_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[1]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.title); save.elements.title.value="Write dynamic HTML"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!text().includes("Write dynamic HTML")) throw new Error("edit-row did not save"); return true;})()"#;
const INLINE_VALIDATION_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.getAttribute("data-hemx-on")==="input"); const input=f.elements.email; input.value="wrong"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"g"})); await new Promise(r=>setTimeout(r,700)); const afterBad=document.body.textContent; input.value="qwdqdqwd@"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"@"})); await new Promise(r=>setTimeout(r,700)); if(document.body.textContent.includes("qwdqdqwd@ is valid")) throw new Error("partial email was accepted"); if(input.value!=="qwdqdqwd@") throw new Error("inline validation reset partial email"); input.value="xyz@example.com"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"m"})); await new Promise(r=>setTimeout(r,700)); const afterGood=document.body.textContent; if(!(afterBad.includes("Email needs a name and dotted domain")&&!afterGood.includes("Email needs a name and dotted domain")&&afterGood.includes("xyz@example.com is valid")&&input.value==="xyz@example.com")) throw new Error("inline validation did not recover"); return true;})()"#;
const ACTIVE_SEARCH_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.query); f.elements.query.value="beta"; f.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true})); await new Promise(r=>setTimeout(r,900)); const results=Array.from(document.querySelectorAll("[data-sid=\"1037530521\"]")).map(e=>e.textContent.trim()); if(!(results.length===2&&results.every(t=>t==="Beta"))) throw new Error("active search did not filter rows"); return true;})()"#;
const ACTIVE_SEARCH_SMOKE: &str = r#"(async()=>{const input=document.querySelector('form input[name="q"]'); if(!input||!input.form) throw new Error("search form not found"); input.value="beta"; input.dispatchEvent(new Event("input",{bubbles:true,cancelable:true})); await new Promise(r=>setTimeout(r,900)); const results=Array.from(document.querySelectorAll("[data-sid=\"1037530521\"]")).map(e=>e.textContent.trim()); if(!(location.search.includes("q=beta")&&results.length===1&&results[0]==="Beta")) throw new Error("active search did not filter URL-state rows"); return true;})()"#;
const DELETE_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const del=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.trim()==="Delete"&&Array.from(f.elements).some(e=>e.name==="id"&&e.value==="1")); del.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(text().includes("Review content")) throw new Error("delete row did not remove row"); return true;})()"#;
const LAZY_LOAD_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load lazy content")); const panel=()=>f.nextElementSibling; const before=panel().textContent.trim(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=panel().textContent.trim(); if(!(after.includes("Lazy content loaded by server update #")&&after!==before)) throw new Error("lazy load did not visibly update"); return true;})()"#;
const CLICK_TO_LOAD_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load more")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 3")&&text().includes("Loaded row 4"))) throw new Error("click-to-load did not append rows"); return true;})()"#;