Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+7
View File
@@ -2,6 +2,13 @@
name = "hemx-js"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Browser runtime assets for Hemx"
keywords = ["hypermedia", "javascript", "web"]
categories = ["web-programming"]
[lib]
path = "src/lib.rs"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# hemx-js
Browser runtime assets embedded by Hemx integrations.
## License
MIT
+18 -27
View File
@@ -1,4 +1,3 @@
// req: ts/001
export type ResourceKind = "slot" | "atom" | "handle" | "form";
export interface ResourceId {
@@ -15,24 +14,27 @@ export interface ResourceRef {
scope: ScopeKey | null;
}
export type Payload =
| { kind: "text"; value: string }
| { kind: "html"; value: string };
export type InsertPosition =
| "first"
| "last"
| { kind: "before"; target: ResourceRef }
| { kind: "after"; target: ResourceRef };
export type ScrollBehavior =
| "preserve"
| "top"
| { kind: "element"; target: ResourceRef };
export type MovePosition = InsertPosition;
export type PatchMode = "morph" | "replace";
export type ScrollBlock = "start" | "center" | "end" | "nearest";
export type ScrollBehavior = "auto" | "instant" | "smooth";
export type HistoryMode = "push" | "replace";
export type Effect =
| { kind: "put"; target: ResourceRef; payload: Payload }
| { kind: "insert"; target: ResourceRef; key: string; payload: Payload }
| { kind: "prepend"; target: ResourceRef; key: string; payload: Payload }
| { kind: "remove"; target: ResourceRef; key: string | null }
| { kind: "move"; target: ResourceRef; key: string; before: string | null }
| { kind: "focus"; target: ResourceRef }
| { kind: "navigate"; url: string; mode: "push" | "replace" | "redirect"; scroll: ScrollBehavior; title: string | null }
| { kind: "emit"; name: string; payload: string };
| { kind: "patch"; target: ResourceRef; mode: PatchMode; html: string }
| { kind: "insert"; target: ResourceRef; position: InsertPosition; html: string }
| { kind: "remove"; target: ResourceRef }
| { kind: "move"; target: ResourceRef; position: MovePosition }
| { kind: "focus"; target: ResourceRef; preventScroll: boolean }
| { kind: "scroll"; target: ResourceRef; block: ScrollBlock; behavior: ScrollBehavior }
| { kind: "visit"; url: string; history: HistoryMode }
| { kind: "dispatch"; event: string; payload: Uint8Array };
export interface EffectBatch {
abiVersion: number;
@@ -45,16 +47,6 @@ export interface AtomSnapshot {
bytes: Uint8Array;
}
export type ClientHandler = (
eventVersion: number,
eventKind: string,
eventValue: string | undefined,
eventChecked: boolean | undefined,
eventKey: string | undefined,
stateVersion: number,
encodedState: string,
) => Uint8Array | Promise<Uint8Array>;
export interface HemxRuntime {
readonly runtimeAbiVersion: number;
roots(): Element[];
@@ -64,7 +56,6 @@ 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): ClientHandler | undefined;
}
declare global {
+59 -185
View File
@@ -19,9 +19,6 @@
const webSockets = new WeakMap();
const atomStores = new WeakMap();
const dragKeys = new WeakMap();
let currentOperationId = null;
const clientHandlers = new Map();
const clientRuns = new WeakMap();
const activeRequests = new Set();
const REQUEST_TIMEOUT_MS = 10_000;
@@ -249,57 +246,6 @@
});
}
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);
try {
if (!handler) throw new Error(`unknown client-local hemx handler: ${name}`);
const stateVersion = Number(root.getAttribute("data-hemx-client-state-version") || "1");
const operationId = crypto.randomUUID();
const wire = await handler(
1,
event.type,
dragKeys.get(root) || el.getAttribute("data-card-id") || ("value" in el ? String(el.value) : undefined),
"checked" in el ? Boolean(el.checked) : undefined,
event.key || undefined,
stateVersion,
root.getAttribute(STATE) || "",
);
if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`);
if (!active()) return;
currentOperationId = operationId;
try {
applyBatch(wire, root);
} finally {
currentOperationId = null;
}
if (name === "reorder_card") {
const key = dragKeys.get(root) || el.getAttribute("data-card-id");
const moved = key ? firstElement(root, (node) => node.getAttribute("data-key") === key) : null;
const focus = moved && (firstElement(moved, (node) => node.tagName === "BUTTON") || moved);
if (focus && typeof focus.focus === "function") focus.focus();
if (matchMedia("(prefers-reduced-motion: reduce)").matches) root.setAttribute("data-hemx-reduced-motion", "");
else root.removeAttribute("data-hemx-reduced-motion");
}
} 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 {
if (clientRuns.get(root) === run) clientRuns.delete(root);
if (el.isConnected) showPending(el, false);
}
}
async function send(el, eventName, source = el) {
if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return;
@@ -464,87 +410,63 @@
return;
}
const scope = root || document;
const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean);
if (missingTarget) {
missing(scope, missingTarget);
return;
for (const op of batch.ops) {
if (!applyOp(scope, op)) return;
}
for (const op of batch.ops) applyOp(scope, op);
bindPolling(scope);
bindRevealed(scope);
}
function canApplyOp(scope, op) {
if (op.kind === "put" && isAtom(op.target)) return null;
if (op.kind === "put" || op.kind === "focus") return targetFor(scope, op.target) ? null : op.target;
if (op.kind === "insert" || op.kind === "prepend") return targetFor(scope, op.target) ? null : op.target;
if (op.kind === "remove") return (op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target)) ? null : op.target;
if (op.kind === "move") return targetFor(scope, op.target) && keyedTarget(scope, op.target.resource.id, op.key) ? null : op.target;
if (op.kind === "navigate" && op.scroll && op.scroll.kind === "element") return targetFor(scope, op.scroll.target) ? null : op.scroll.target;
function positionTarget(scope, position) {
if (position.kind === "before" || position.kind === "after") return targetFor(scope, position.target);
return null;
}
function applyOp(scope, op) {
if (op.kind === "put") {
if (isAtom(op.target)) {
atomStore(scope).set(String(op.target.resource.id), op.payload.value);
forEachElement(scope, (element) => {
if (element.getAttribute("data-aid") === String(op.target.resource.id)) {
putPayload(element, op.payload);
}
});
return true;
if (op.kind === "patch") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
if (op.mode === "replace") {
const nodes = fragmentNodes({ kind: "html", value: op.html }, null, null);
target.replaceWith(...nodes);
} else target.innerHTML = op.html;
} else if (op.kind === "insert") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
if (op.position === "first") target.prepend(...fragmentNodes({ kind: "html", value: op.html }, null, null));
else if (op.position === "last") target.append(...fragmentNodes({ kind: "html", value: op.html }, null, null));
else {
const anchor = positionTarget(scope, op.position);
if (!anchor) return missing(scope, op.position.target);
const nodes = fragmentNodes({ kind: "html", value: op.html }, null, null);
for (const node of nodes) anchor.parentNode.insertBefore(node, op.position.kind === "before" ? anchor : anchor.nextSibling);
}
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
if (op.target.scope && op.target.scope.kind === "key" && op.payload.kind === "html") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id);
else putPayload(target, op.payload);
} else if (op.kind === "insert" || op.kind === "prepend") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id);
target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
} else if (op.kind === "remove") {
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target);
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
target.remove();
} else if (op.kind === "move") {
const target = targetFor(scope, op.target);
const item = keyedTarget(scope, op.target.resource.id, op.key);
if (!target || !item) return missing(scope, op.target);
const before = op.before && keyedTarget(scope, op.target.resource.id, op.before);
target.insertBefore(item, before || null);
if (!target) return missing(scope, op.target);
if (op.position === "first") target.parentNode.prepend(target);
else if (op.position === "last") target.parentNode.append(target);
else {
const anchor = positionTarget(scope, op.position);
if (!anchor) return missing(scope, op.position.target);
anchor.parentNode.insertBefore(target, op.position.kind === "before" ? anchor : anchor.nextSibling);
}
} else if (op.kind === "focus") {
const target = targetFor(scope, op.target);
if (target && target.focus) target.focus();
if (target && target.focus) target.focus({ preventScroll: op.preventScroll });
else return missing(scope, op.target);
} else if (op.kind === "navigate") {
if (op.mode === "redirect") location.href = op.url;
else {
history[op.mode === "replace" ? "replaceState" : "pushState"]({ hemx: true }, "", op.url);
if (op.scroll === "top") scrollTo(0, 0);
else if (op.scroll && op.scroll.kind === "element") {
const target = targetFor(scope, op.scroll.target);
if (target) target.scrollIntoView();
}
if (op.title) document.title = op.title;
}
} else if (op.kind === "emit") {
let payload = op.payload;
if (currentOperationId && op.name === "hemx:sync-patch") {
const event = JSON.parse(payload);
const patch = event && Object.getPrototypeOf(event) === Object.prototype && "patch" in event
? event.patch
: event;
if (patch.idempotencyKey !== "$hemx-interaction" || patch.operationId !== "$hemx-interaction") {
throw new Error("hemx sync patch is missing its interaction identity");
}
patch.idempotencyKey = currentOperationId;
patch.operationId = currentOperationId;
payload = JSON.stringify(event);
}
handleRuntimeEvent(scope, op.name, payload);
emit(scope, op.name, payload);
} else if (op.kind === "scroll") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
target.scrollIntoView({ block: op.block, behavior: op.behavior });
} else if (op.kind === "visit") {
navigateUrl(op.url, scope, op.history).catch(() => { location.href = op.url; });
} else if (op.kind === "dispatch") {
emit(scope, op.event, op.payload);
}
return true;
}
@@ -561,6 +483,7 @@
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node);
while (stack.length) {
const node = stack.shift();
if (node.hasAttribute && node.hasAttribute(ROOT)) continue;
if (predicate(node)) return node;
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
}
@@ -667,28 +590,6 @@
return nodes;
}
function handleRuntimeEvent(scope, name, payload) {
if (name === "hemx:form-reset") {
const form = generatedFormTarget(scope, payload);
if (form && form.reset) {
form.reset();
forEachElement(form, (el) => {
if (el.hasAttribute("data-hemx-error-for")) {
if (isInputControl(el)) el.setCustomValidity("");
else el.textContent = "";
}
});
}
} else if (name === "hemx:form-error") {
const [id, field, message] = String(payload).split("\u001f");
const target = formErrorTarget(scope, id, field);
if (target) putFormError(target, message || "");
} else if (name === "hemx:form-disable-while-pending") {
const form = generatedFormTarget(scope, payload);
if (form) form.setAttribute("data-hemx-disable-while-pending", "");
}
}
function missing(root, target) {
emit(root, "hemx:missing-target", target);
return false;
@@ -772,10 +673,6 @@
function bindRoot(root) {
["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => {
root.addEventListener(name, (event) => {
if (name === "keydown") {
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-client"));
if (direct && !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
}
if (name === "dragstart") {
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-key"));
if (item) {
@@ -800,18 +697,9 @@
}
if (name === "click") {
const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID));
const active = direct || closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-client"));
if (active && handlesEvent(active, "click")) {
if (direct && handlesEvent(direct, "click")) {
event.preventDefault();
if (active.hasAttribute("data-hemx-client")) {
runClient(active, event).catch((error) => emit(root, "hemx:client-error", {
handler: active.getAttribute("data-hemx-client"),
message: String(error),
fallback: false,
}));
} else {
schedule(active, name);
}
schedule(direct, name);
return;
}
}
@@ -829,21 +717,13 @@
}
}
let el = closestInRoot(event.target, root, (node) =>
node.hasAttribute(HID) || node.hasAttribute("data-hemx-client") ||
node.hasAttribute(HID) ||
(node.tagName === "FORM" && (pageFormHistoryMode(event.submitter || node, node) || boostRoot(node)))
);
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 || !handlesEvent(el, name)) return;
event.preventDefault();
if (el.hasAttribute("data-hemx-client")) {
runClient(el, event).catch((error) => emit(root, "hemx:client-error", {
handler: el.getAttribute("data-hemx-client"),
message: String(error),
fallback: false,
}));
} else {
schedule(el, name, event.submitter || el);
}
schedule(el, name, event.submitter || el);
});
});
bindPolling(root);
@@ -1077,6 +957,7 @@
const str = () => new TextDecoder("utf-8", { fatal: true }).decode(
need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx string")),
);
const bytesField = () => need(boundedLength(u32(), MAX_WIRE_FIELD_BYTES, "hemx bytes"));
const enumValue = (values, field) => {
const discriminant = u8();
if (discriminant >= values.length) throw new Error(`unknown ${field} ${discriminant}`);
@@ -1097,24 +978,24 @@
throw new Error(`unknown hemx scope ${kind}`);
};
const ref = () => ({ resource: resource(), scope: scope() });
const payload = () => ({ kind: enumValue(["text", "html"], "hemx payload"), value: str() });
const scroll = () => {
const position = () => {
const kind = u8();
if (kind === 0) return "preserve";
if (kind === 1) return "top";
if (kind === 2) return { kind: "element", target: ref() };
throw new Error(`unknown hemx scroll behavior ${kind}`);
if (kind === 0) return "first";
if (kind === 1) return "last";
if (kind === 2) return { kind: "before", target: ref() };
if (kind === 3) return { kind: "after", target: ref() };
throw new Error(`unknown hemx position ${kind}`);
};
const effect = () => {
const kind = u8();
if (kind === 0) return { kind: "put", target: ref(), payload: payload() };
if (kind === 1) return { kind: "insert", target: ref(), key: str(), payload: payload() };
if (kind === 2) return { kind: "prepend", target: ref(), key: str(), payload: payload() };
if (kind === 3) return { kind: "remove", target: ref(), key: option(str) };
if (kind === 4) return { kind: "move", target: ref(), key: str(), before: option(str) };
if (kind === 5) return { kind: "focus", target: ref() };
if (kind === 6) return { kind: "navigate", url: str(), mode: enumValue(["push", "replace", "redirect"], "hemx navigation mode"), scroll: scroll(), title: option(str) };
if (kind === 7) return { kind: "emit", name: str(), payload: str() };
if (kind === 0) return { kind: "patch", target: ref(), mode: enumValue(["morph", "replace"], "hemx patch mode"), html: str() };
if (kind === 1) return { kind: "insert", target: ref(), position: position(), html: str() };
if (kind === 2) return { kind: "remove", target: ref() };
if (kind === 3) return { kind: "move", target: ref(), position: position() };
if (kind === 4) return { kind: "focus", target: ref(), preventScroll: u8() !== 0 };
if (kind === 5) return { kind: "scroll", target: ref(), block: enumValue(["start", "center", "end", "nearest"], "hemx scroll block"), behavior: enumValue(["auto", "instant", "smooth"], "hemx scroll behavior") };
if (kind === 6) return { kind: "visit", url: str(), history: enumValue(["push", "replace"], "hemx history mode") };
if (kind === 7) return { kind: "dispatch", event: str(), payload: bytesField() };
throw new Error(`unknown hemx effect ${kind}`);
};
const vec = (read) => {
@@ -1164,7 +1045,6 @@
}
function cleanupRemovedRoot(root) {
clientRuns.delete(root);
const source = sseSources.get(root);
if (source) source.close();
sseSources.delete(root);
@@ -1266,12 +1146,6 @@
decodeBatch,
atomValue,
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;
},
});
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
+42 -54
View File
@@ -1,11 +1,10 @@
#[test]
fn runtime_abi_version_is_explicit_and_stable() {
assert_eq!(hemx_js::RUNTIME_ABI_VERSION, 1); // test req: abi/001
assert_eq!(hemx_js::RUNTIME_ABI_VERSION, 1);
}
#[test]
fn runtime_exposes_debug_api_before_startup_side_effects() {
// req: runtime/002
let source = hemx_js::RUNTIME_JS;
let api = source
@@ -23,7 +22,6 @@ fn runtime_exposes_debug_api_before_startup_side_effects() {
#[test]
fn runtime_reports_http_failures_without_applying_effects() {
// req: runtime/005
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function httpError(response)"));
@@ -50,7 +48,6 @@ fn runtime_posts_urlencoded_forms_by_default() {
#[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)"));
@@ -68,7 +65,6 @@ fn runtime_turns_page_get_forms_into_url_state_navigation() {
#[test]
fn runtime_preserves_multipart_file_upload_fallback_shape() {
// req: multipart/003
let source = hemx_js::RUNTIME_JS;
assert!(source.contains(
@@ -81,7 +77,6 @@ fn runtime_preserves_multipart_file_upload_fallback_shape() {
#[test]
fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
// req: pitch/002 req: runtime/001 req: target_policy/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function firstElement(scope, predicate)"));
@@ -97,7 +92,6 @@ fn runtime_uses_root_scoped_walks_not_dom_selector_apis() {
#[test]
fn runtime_targets_generated_resources_not_response_selectors() {
// req: target_policy/001 req: target_policy/002
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function targetFor(scope, ref)"));
@@ -105,22 +99,20 @@ fn runtime_targets_generated_resources_not_response_selectors() {
assert!(source.contains("function firstElement(scope, predicate)"));
assert!(source.contains("function generatedResource(el, id)"));
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))"));
assert!(
source.contains("const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id)")
);
assert!(source.contains("if (op.target.scope && op.target.scope.kind === \"key\" && op.payload.kind === \"html\") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id)"));
assert!(source.contains("function replacePayload(target, payload, key, resourceId)"));
assert!(source.contains("if (node.hasAttribute && node.hasAttribute(ROOT)) continue"));
assert!(source.contains("if (!applyOp(scope, op)) return"));
assert!(!source.contains("canApplyOp"));
assert!(source.contains("if (op.position === \"first\") target.prepend"));
assert!(source.contains("else if (op.position === \"last\") target.append"));
assert!(source.contains("target.replaceWith(...nodes)"));
assert!(source.contains("firstElement.setAttribute(\"data-sid\", resourceId)"));
assert!(source.contains("const target = generatedTarget(scope, id)"));
assert!(source.contains("if (!target) return missing(scope, op.target)"));
assert!(source.contains("emit(scope, op.event, op.payload)"));
assert!(!source.contains("handleRuntimeEvent"));
assert!(!source.contains("data-hemx-target"));
assert!(!source.contains("data-hemx-select"));
}
#[test]
fn runtime_interval_dispatch_avoids_duplicate_timers() {
// req: convention/005
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const everyTimers = new WeakMap()"));
@@ -138,7 +130,6 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
#[test]
fn runtime_supports_tiny_delay_and_revealed_scheduling() {
// req: convention/001 req: convention/003 req: convention/005
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("data-hemx-delay"));
@@ -146,26 +137,24 @@ fn runtime_supports_tiny_delay_and_revealed_scheduling() {
assert!(source.contains("const revealObservers = new WeakMap()"));
assert!(source.contains("const revealed = new WeakSet()"));
assert!(source.contains("data-hemx-revealed"));
assert!(source.contains("data-hemx-revealed-ahead")); // req: convention/011 test
assert!(source.contains("data-hemx-revealed-ahead"));
assert!(source.contains("typeof IntersectionObserver === \"undefined\""));
assert!(source.contains("rootMargin"));
assert!(source.contains("addEventListener(\"resize\", () => rebindRevealed(false))"));
assert!(source.contains("schedule(entry.target, \"revealed\")"));
assert!(source.contains("window.addEventListener(\"pageshow\", restoreRevealed)"));
assert!(source.contains("revealed.delete(el)")); // req: convention/014 test
assert!(source.contains("revealed.delete(el)"));
assert!(source.contains("record.addedNodes.forEach((node) =>"));
assert!(source.contains("descendantRoots(node).forEach(bindRoot)"));
assert!(source.contains("const owner = rootOf(node.parentElement)"));
assert!(source.contains("bindPolling(owner)"));
assert!(source.contains("bindRevealed(owner)"));
assert!(source.contains(
"for (const op of batch.ops) applyOp(scope, op);\n bindPolling(scope);\n bindRevealed(scope);"
)); // req: runtime/007 test
assert!(source.contains("if (!applyOp(scope, op)) return;"));
assert!(source.contains("bindPolling(scope);\n bindRevealed(scope);"));
}
#[test]
fn runtime_toggles_pending_conventions_around_requests() {
// req: convention/007 req: convention/008
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function showPending(el, on)"));
@@ -202,7 +191,6 @@ fn runtime_toggles_pending_conventions_around_requests() {
#[test]
fn runtime_confirms_before_handler_dispatch() {
// req: convention/004
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("el.getAttribute(\"data-hemx-confirm\") && !confirm(el.getAttribute(\"data-hemx-confirm\"))"));
@@ -211,7 +199,6 @@ fn runtime_confirms_before_handler_dispatch() {
#[test]
fn runtime_bounds_and_cancels_ordinary_requests() {
// test req: operations/003
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const REQUEST_TIMEOUT_MS = 10_000"));
@@ -225,7 +212,6 @@ fn runtime_bounds_and_cancels_ordinary_requests() {
#[test]
fn runtime_fetches_with_same_origin_credentials() {
// req: auth/005
let source = hemx_js::RUNTIME_JS;
assert_eq!(source.matches("credentials: \"same-origin\"").count(), 2);
@@ -242,7 +228,6 @@ fn runtime_handles_get_forms_without_request_body() {
#[test]
fn runtime_clicking_submitter_schedules_form_submit() {
// req: convention/004 req: runtime/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function formOwner(el)"));
@@ -266,7 +251,6 @@ fn runtime_clicking_submitter_schedules_form_submit() {
#[test]
fn runtime_supports_drag_drop_params_without_user_js() {
// req: htmx_equivalents/002, req: dx/008
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const dragKeys = new WeakMap()"));
@@ -278,7 +262,6 @@ fn runtime_supports_drag_drop_params_without_user_js() {
#[test]
fn runtime_supports_queued_request_policy() {
// req: convention/006
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function normalizedPolicy(value)"));
@@ -293,7 +276,6 @@ fn runtime_supports_queued_request_policy() {
#[test]
fn runtime_latest_request_policy_releases_superseded_pending_state() {
// req: convention/006 req: convention/007
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("if (active && policy === \"latest\") {\n active.abort.abort();\n showPending(target, false);\n }"));
@@ -302,7 +284,6 @@ fn runtime_latest_request_policy_releases_superseded_pending_state() {
#[test]
fn runtime_parallel_request_policy_releases_each_pending_state() {
// req: convention/006 req: convention/007 req: convention/008
let source = hemx_js::RUNTIME_JS;
assert!(source.contains(
@@ -322,7 +303,7 @@ fn runtime_exposes_page_swap_hooks() {
assert!(source.contains("history.pushState"));
assert!(source.contains("snapshotPage(root)"));
assert!(source.contains("restorePage(event.state, root)"));
assert!(source.contains("scrollTo(state.scrollX, state.scrollY)")); // req: page_swap/005 test
assert!(source.contains("scrollTo(state.scrollX, state.scrollY)"));
assert!(source.contains("popstate"));
assert!(source.contains("x-hemx-title"));
assert!(source.contains("x-hemx-fingerprint"));
@@ -332,7 +313,6 @@ fn runtime_exposes_page_swap_hooks() {
#[test]
fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
// req: page_swap/005 req: page_swap/006 req: runtime/004
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("if (mode === \"none\") location.reload();"));
@@ -341,7 +321,6 @@ fn runtime_popstate_failed_partials_reload_instead_of_stale_ui() {
#[test]
fn runtime_preserves_native_navigation_escape_hatches() {
// req: page_swap/007 req: failure/006
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function sameOriginNav(event, anchor)"));
@@ -355,7 +334,6 @@ fn runtime_preserves_native_navigation_escape_hatches() {
#[test]
fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
// req: abi/003 req: abi/004 req: runtime/004
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function compatibleFingerprint(response, root)"));
@@ -368,7 +346,6 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
#[test]
fn runtime_malformed_bootstrap_state_reports_and_continues() {
// req: state/004 req: state/006 req: runtime/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function bootstrapState(root)"));
@@ -381,7 +358,6 @@ fn runtime_malformed_bootstrap_state_reports_and_continues() {
#[test]
fn runtime_applies_sse_effect_batches_inside_roots() {
// req: push/001 req: push/003 req: push/006 req: runtime/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const sseSources = new WeakMap()"));
@@ -400,7 +376,6 @@ fn runtime_applies_sse_effect_batches_inside_roots() {
#[test]
fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
// req: push/001 req: push/003 req: push/009 req: runtime/001 req: failure/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const webSockets = new WeakMap()"));
@@ -417,7 +392,6 @@ fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
#[test]
fn runtime_page_swaps_lowered_slot_ids() {
// req: wire/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function replaceLoweredSlots(scope, doc)"));
@@ -441,21 +415,18 @@ fn runtime_keeps_form_field_targets_separate_from_error_targets() {
}
#[test]
fn runtime_preflights_batches_before_applying_ops() {
fn runtime_applies_batches_in_order_and_stops_on_failure() {
let source = hemx_js::RUNTIME_JS;
assert!(source.contains(
"const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)"
));
assert!(source.contains("function canApplyOp(scope, op)"));
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
assert!(source.contains("for (const op of batch.ops)"));
assert!(source.contains("if (!applyOp(scope, op)) return;"));
assert!(!source.contains("function canApplyOp(scope, op)"));
}
#[test]
fn runtime_ships_typescript_definitions() {
let source = hemx_js::RUNTIME_D_TS;
assert!(source.contains("req: ts/001"));
assert!(source.contains("export interface EffectBatch"));
assert!(source.contains("fingerprint: bigint"));
assert!(source.contains("export type Effect ="));
@@ -464,14 +435,31 @@ fn runtime_ships_typescript_definitions() {
}
#[test]
fn runtime_clears_error_for_elements_on_form_reset() {
// req: runtime/005 req: form_effects/003
fn runtime_dispatches_payload_without_application_policy() {
let source = hemx_js::RUNTIME_JS;
assert!(source.contains(r#"if (name === "hemx:form-reset")"#));
assert!(source.contains("form.reset();"));
assert!(source.contains(r#"if (el.hasAttribute("data-hemx-error-for"))"#));
assert!(source.contains("forEachElement(form, (el) => {"));
assert!(source.contains("el.setCustomValidity(\"\")"));
assert!(source.contains("el.textContent = \"\""));
assert!(source.contains("emit(scope, op.event, op.payload)"));
assert!(!source.contains("hemx:form-reset"));
assert!(!source.contains("handleRuntimeEvent"));
}
#[test]
fn runtime_executes_every_closed_effect_with_owned_targets() {
let source = hemx_js::RUNTIME_JS;
for kind in [
"patch", "insert", "remove", "move", "focus", "scroll", "visit", "dispatch",
] {
assert!(
source.contains(&format!("op.kind === \"{kind}\"")),
"missing {kind} executor"
);
}
assert!(source.contains("target.replaceWith(...nodes)"));
assert!(source.contains("target.parentNode.prepend(target)"));
assert!(source.contains("target.parentNode.append(target)"));
assert!(source.contains("target.focus({ preventScroll: op.preventScroll })"));
assert!(source.contains("target.scrollIntoView({ block: op.block, behavior: op.behavior })"));
assert!(source.contains("navigateUrl(op.url, scope, op.history).catch"));
assert!(source.contains("emit(scope, op.event, op.payload)"));
}