Implement closed typed effect path
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# hemx-js
|
||||
|
||||
Browser runtime assets embedded by Hemx integrations.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
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);
|
||||
|
||||
+42
-54
@@ -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)"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user