feat(wasm): suppress stale client completions

req: client_local/011\nreq: client_local/012\nreq: operations/003
This commit is contained in:
slhx agent
2026-07-13 13:17:58 +02:00
parent ac525fe572
commit dd5ea0827f
6 changed files with 146 additions and 7 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ encryption, retention, backup, and deployment policy remain host concerns.
## Slice 2 — direct manipulation that survives interruption ## 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. - [ ] **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. - **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. - **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`. - **Requirements:** `client_local/011-014`, `accessibility/001-007`, `operations/002-003`, `performance/001`, `performance/003`, `milestone/001`.
@@ -1,4 +1,4 @@
<main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1" data-hemx-client-module="/client_local.js"> <main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1" data-hemx-client-module="/client_local.js">
<section data-hemx-slot="counter_panel">idle</section> <section data-hemx-slot="counter_panel">idle</section>
<button type="button" data-hemx-handle="increment" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-fallback data-hemx-pending-class="is-pending">Increment locally</button> <button type="button" data-hemx-handle="increment" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-policy="latest" data-hemx-client-fallback data-hemx-pending-class="is-pending">Increment locally</button>
</main> </main>
+9
View File
@@ -1676,6 +1676,7 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-client-event" | "data-hemx-client-event"
| "data-hemx-client-fallback" | "data-hemx-client-fallback"
| "data-hemx-client-module" | "data-hemx-client-module"
| "data-hemx-client-policy"
| "data-hemx-client-state-version" | "data-hemx-client-state-version"
| "data-hemx-pending-class" | "data-hemx-pending-class"
| "data-hemx-indicator" | "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", "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) => { "data-hemx-client-module" if !valid_client_module(value) => {
return Err(invalid_hemx_value( return Err(invalid_hemx_value(
path, path,
+1 -1
View File
@@ -64,7 +64,7 @@ export interface HemxRuntime {
decodeBatch(buffer: ArrayBuffer): EffectBatch; decodeBatch(buffer: ArrayBuffer): EffectBatch;
atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined; atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined;
decodeAtomState(encoded: string): AtomSnapshot[]; decodeAtomState(encoded: string): AtomSnapshot[];
registerClientHandler(name: string, handler: ClientHandler): void; registerClientHandler(name: string, handler: ClientHandler): ClientHandler | undefined;
} }
declare global { declare global {
+50 -1
View File
@@ -19,6 +19,7 @@
const atomStores = new WeakMap(); const atomStores = new WeakMap();
const dragKeys = new WeakMap(); const dragKeys = new WeakMap();
const clientHandlers = new Map(); const clientHandlers = new Map();
const clientRuns = new WeakMap();
function roots() { function roots() {
const found = []; const found = [];
@@ -241,6 +242,11 @@
async function runClient(el, event) { async function runClient(el, event) {
const name = el.getAttribute("data-hemx-client"); const name = el.getAttribute("data-hemx-client");
const root = rootOf(el); 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); const handler = clientHandlers.get(name);
showError(el, null); showError(el, null);
showPending(el, true); showPending(el, true);
@@ -257,14 +263,17 @@
root.getAttribute(STATE) || "", root.getAttribute(STATE) || "",
); );
if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`); if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`);
if (!active()) return;
applyBatch(wire, root); applyBatch(wire, root);
} catch (error) { } catch (error) {
if (!active()) return;
const fallback = el.hasAttribute("data-hemx-client-fallback"); const fallback = el.hasAttribute("data-hemx-client-fallback");
showError(el, error); showError(el, error);
emit(root, "hemx:client-error", { handler: name, message: String(error), fallback }); emit(root, "hemx:client-error", { handler: name, message: String(error), fallback });
if (fallback) await send(el, event.type, el); if (fallback) await send(el, event.type, el);
} finally { } 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"; 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() { function start() {
roots().forEach((root) => { roots().forEach((root) => {
try { try {
@@ -961,6 +999,15 @@
emit(root, "hemx:sse-error", String(error)); 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 { try {
history.replaceState(history.state || { hemx: true }, "", location.href); history.replaceState(history.state || { hemx: true }, "", location.href);
} catch (error) { } catch (error) {
@@ -985,7 +1032,9 @@
decodeAtomState, decodeAtomState,
registerClientHandler(name, handler) { registerClientHandler(name, handler) {
if (!name || typeof handler !== "function") throw new Error("client handler registration requires a name and function"); 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); clientHandlers.set(name, handler);
return previous;
}, },
}); });
+83 -2
View File
@@ -15,6 +15,7 @@ const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
#[tokio::test] #[tokio::test]
async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> { 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/005 req: client_local/009 req: client_local/010
// req: client_local/011 req: client_local/012
// test: client_local/014 // test: client_local/014
let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
@@ -45,11 +46,47 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
) )
.await?; .await?;
let network_before = resource_count(&driver).await?; let network_before = resource_count(&driver).await?;
driver 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']")) .find(By::Css("[data-hemx-client='increment']"))
.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? .await?
.click() .prop("textContent")
.await?
.unwrap_or_default(),
"idle",
"superseded completion applied stale effects"
);
driver
.execute(
"window.__resolveClientRuns[1](); return true",
Vec::new(),
)
.await?; .await?;
wait_until( wait_until(
&driver, &driver,
@@ -63,6 +100,12 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
"client handler made a network request" "client handler made a network request"
); );
driver
.execute(
"window.hemx.registerClientHandler('increment', window.__actualClientHandler); return true",
Vec::new(),
)
.await?;
driver driver
.execute( .execute(
"document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '2'); return true", "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), .unwrap_or(false),
"invalid input must restore pending UI" "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>(()) Ok::<(), WebDriverError>(())
} }
.await; .await;