feat(wasm): suppress stale client completions
req: client_local/011\nreq: client_local/012\nreq: operations/003
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
-1
@@ -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 {
|
||||
|
||||
+50
-1
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user