From dd5ea0827f004326b8b82400d15f36a09f687c51 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 13 Jul 2026 13:17:58 +0200 Subject: [PATCH] feat(wasm): suppress stale client completions req: client_local/011\nreq: client_local/012\nreq: operations/003 --- PLAN.md | 2 +- .../client_local/templates/client_local.heml | 2 +- hemx-build/src/lib.rs | 9 ++ hemx-js/runtime/hemx.d.ts | 2 +- hemx-js/runtime/hemx.js | 51 ++++++++++- hemx-wasm/tests/browser.rs | 87 ++++++++++++++++++- 6 files changed, 146 insertions(+), 7 deletions(-) diff --git a/PLAN.md b/PLAN.md index 735ce59..fa2b66d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -24,7 +24,7 @@ encryption, retention, backup, and deployment policy remain host concerns. ## Slice 2 — direct manipulation that survives interruption - [ ] **User value:** Kanban drag/reorder follows the pointer immediately, remains keyboard operable, and cannot apply stale work after cancellation or root removal. -- **State:** Blocked by Slice 1. +- **State:** In progress. Client-local runs now use a validated `latest`/`drop` policy; superseded completions and completions after root removal cannot apply effects, and removed roots release runtime-owned request/state/run references. Canonical Kanban wiring, pointer/keyboard parity, focus/status, reduced-motion, and measured response/frame budgets remain. - **Build:** use the client handler in the canonical Kanban path; add cancellation/supersession, root-owned state cleanup, keyboard equivalent, focus/status behavior, reduced-motion behavior, and measured response/frame budgets. - **Refusals:** no persistence, collaboration, or animation framework yet. - **Requirements:** `client_local/011-014`, `accessibility/001-007`, `operations/002-003`, `performance/001`, `performance/003`, `milestone/001`. diff --git a/examples/client_local/templates/client_local.heml b/examples/client_local/templates/client_local.heml index 365a6d9..70d6feb 100644 --- a/examples/client_local/templates/client_local.heml +++ b/examples/client_local/templates/client_local.heml @@ -1,4 +1,4 @@
idle
- +
diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 5244edc..891ec13 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -1676,6 +1676,7 @@ fn known_hemx_attr(name: &str) -> bool { | "data-hemx-client-event" | "data-hemx-client-fallback" | "data-hemx-client-module" + | "data-hemx-client-policy" | "data-hemx-client-state-version" | "data-hemx-pending-class" | "data-hemx-indicator" @@ -1728,6 +1729,14 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i "expected a non-empty client handler name", )); } + "data-hemx-client-policy" if !matches!(value.trim(), "latest" | "drop") => { + return Err(invalid_hemx_value( + path, + &attr.name, + value, + "expected `latest` or `drop`", + )); + } "data-hemx-client-module" if !valid_client_module(value) => { return Err(invalid_hemx_value( path, diff --git a/hemx-js/runtime/hemx.d.ts b/hemx-js/runtime/hemx.d.ts index cbd5f63..af2e068 100644 --- a/hemx-js/runtime/hemx.d.ts +++ b/hemx-js/runtime/hemx.d.ts @@ -64,7 +64,7 @@ export interface HemxRuntime { decodeBatch(buffer: ArrayBuffer): EffectBatch; atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined; decodeAtomState(encoded: string): AtomSnapshot[]; - registerClientHandler(name: string, handler: ClientHandler): void; + registerClientHandler(name: string, handler: ClientHandler): ClientHandler | undefined; } declare global { diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index c579555..319ccff 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -19,6 +19,7 @@ const atomStores = new WeakMap(); const dragKeys = new WeakMap(); const clientHandlers = new Map(); + const clientRuns = new WeakMap(); function roots() { const found = []; @@ -241,6 +242,11 @@ async function runClient(el, event) { const name = el.getAttribute("data-hemx-client"); const root = rootOf(el); + const policy = el.getAttribute("data-hemx-client-policy") || "latest"; + const run = { root, generation: (clientRuns.get(root)?.generation || 0) + 1 }; + if (policy === "drop" && clientRuns.has(root)) return; + clientRuns.set(root, run); + const active = () => clientRuns.get(root) === run && root.isConnected; const handler = clientHandlers.get(name); showError(el, null); showPending(el, true); @@ -257,14 +263,17 @@ root.getAttribute(STATE) || "", ); if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`); + if (!active()) return; applyBatch(wire, root); } catch (error) { + if (!active()) return; const fallback = el.hasAttribute("data-hemx-client-fallback"); showError(el, error); emit(root, "hemx:client-error", { handler: name, message: String(error), fallback }); if (fallback) await send(el, event.type, el); } finally { - showPending(el, false); + if (clientRuns.get(root) === run) clientRuns.delete(root); + if (el.isConnected) showPending(el, false); } } @@ -943,6 +952,35 @@ anchor.origin === location.origin && !anchor.download && anchor.target !== "_blank"; } + function descendantRoots(node) { + const roots = []; + for (const child of node.children || []) { + if (child.hasAttribute(ROOT)) roots.push(child); + roots.push(...descendantRoots(child)); + } + return roots; + } + + function stopDescendantPolling(node) { + for (const child of node.children || []) { + if (child.hasAttribute("data-hemx-every") || child.hasAttribute("data-hemx-interval")) { + stopPolling(child); + } + stopDescendantPolling(child); + } + } + + function cleanupRemovedRoot(root) { + clientRuns.delete(root); + const source = sseSources.get(root); + if (source) source.close(); + sseSources.delete(root); + const observer = revealObservers.get(root); + if (observer) observer.disconnect(); + revealObservers.delete(root); + stopDescendantPolling(root); + } + function start() { roots().forEach((root) => { try { @@ -961,6 +999,15 @@ emit(root, "hemx:sse-error", String(error)); } }); + new MutationObserver((records) => { + records.forEach((record) => { + record.removedNodes.forEach((node) => { + if (!(node instanceof Element)) return; + if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node); + descendantRoots(node).forEach(cleanupRemovedRoot); + }); + }); + }).observe(document.documentElement, { childList: true, subtree: true }); try { history.replaceState(history.state || { hemx: true }, "", location.href); } catch (error) { @@ -985,7 +1032,9 @@ decodeAtomState, registerClientHandler(name, handler) { if (!name || typeof handler !== "function") throw new Error("client handler registration requires a name and function"); + const previous = clientHandlers.get(name); clientHandlers.set(name, handler); + return previous; }, }); diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index d1915a8..d2b02fb 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -15,6 +15,7 @@ const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); #[tokio::test] async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> { // req: client_local/005 req: client_local/009 req: client_local/010 + // req: client_local/011 req: client_local/012 // test: client_local/014 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -45,11 +46,47 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul ) .await?; let network_before = resource_count(&driver).await?; - driver + .execute( + r#" + window.__resolveClientRuns = []; + const actual = window.hemx.registerClientHandler('increment', (...args) => new Promise((resolve) => { + window.__resolveClientRuns.push(() => resolve(actual(...args))); + })); + window.__actualClientHandler = actual; + return true; + "#, + Vec::new(), + ) + .await?; + let button = driver .find(By::Css("[data-hemx-client='increment']")) - .await? - .click() + .await?; + button.click().await?; + button.click().await?; + wait_until(&driver, "return window.__resolveClientRuns.length === 2").await?; + driver + .execute( + "window.__resolveClientRuns[0](); return true", + Vec::new(), + ) + .await?; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!( + driver + .find(By::Css("[data-sid]")) + .await? + .prop("textContent") + .await? + .unwrap_or_default(), + "idle", + "superseded completion applied stale effects" + ); + driver + .execute( + "window.__resolveClientRuns[1](); return true", + Vec::new(), + ) .await?; wait_until( &driver, @@ -63,6 +100,12 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul "client handler made a network request" ); + driver + .execute( + "window.hemx.registerClientHandler('increment', window.__actualClientHandler); return true", + Vec::new(), + ) + .await?; driver .execute( "document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '2'); return true", @@ -102,6 +145,44 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul .unwrap_or(false), "invalid input must restore pending UI" ); + + driver + .execute( + r#" + window.__resolveUnmount = null; + const actual = window.hemx.registerClientHandler('increment', (...args) => new Promise((resolve) => { + window.__resolveUnmount = () => resolve(actual(...args)); + })); + document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '1'); + return true; + "#, + Vec::new(), + ) + .await?; + driver + .find(By::Css("[data-hemx-client='increment']")) + .await? + .click() + .await?; + driver + .execute( + "const root = document.querySelector('[data-hemx-root]'); window.__removedRoot = root; root.remove(); window.__resolveUnmount(); return true", + Vec::new(), + ) + .await?; + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + driver + .execute( + "return !window.__removedRoot.textContent.includes('updated by Rust/WASM')", + Vec::new(), + ) + .await? + .json() + .as_bool() + .unwrap_or(false), + "unmounted root accepted a late effect" + ); Ok::<(), WebDriverError>(()) } .await;