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
+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);