Implement closed typed effect path
This commit is contained in:
Vendored
+18
-27
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user