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
|
## 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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
@@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
.await?;
|
||||||
.click()
|
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?;
|
.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user