Enforce root-owned adapter lifecycles

This commit is contained in:
tmk241
2026-09-01 09:43:38 +02:00
parent 53f0a5db1a
commit 9e12b1eb46
5 changed files with 124 additions and 139 deletions
+75 -106
View File
@@ -4,7 +4,7 @@
const RESOURCE = "data-hemx-resource";
const runtimeAbiVersion = 1;
const BUILD = "data-hemx-build";
const STATE = "data-hemx-st";
const ISLAND = "data-hemx-island";
const pending = new WeakMap();
const queues = new WeakMap();
const timers = new WeakMap();
@@ -17,7 +17,7 @@
const disabledStates = new WeakMap();
const sseSources = new WeakMap();
const webSockets = new WeakMap();
const atomStores = new WeakMap();
const boundRoots = new WeakSet();
const dragKeys = new WeakMap();
const activeRequests = new Set();
const REQUEST_TIMEOUT_MS = 10_000;
@@ -436,8 +436,9 @@
if (!target) return missing(scope, op.target);
if (op.mode === "replace") {
const nodes = fragmentNodes({ kind: "html", value: op.html }, null, null);
preserveIslands(target, nodes);
target.replaceWith(...nodes);
} else target.innerHTML = op.html;
} else morphChildren(target, op.html);
} else if (op.kind === "insert") {
const target = targetFor(scope, op.target);
if (!target) return missing(scope, op.target);
@@ -480,7 +481,6 @@
}
function targetFor(scope, ref) {
if (isAtom(ref)) return null;
if (ref.scope && ref.scope.kind === "key") return keyedTarget(scope, ref.resource.id, ref.scope.value);
if (ref.scope && ref.scope.kind === "field") return fieldTarget(scope, ref.resource.id, ref.scope.value);
return generatedTarget(scope, ref.resource.id);
@@ -491,7 +491,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 (node.hasAttribute && (node.hasAttribute(ROOT) || node.hasAttribute(ISLAND))) continue;
if (predicate(node)) return node;
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
}
@@ -539,24 +539,6 @@
return isInputControl(el) || (el && el.tagName === "BUTTON");
}
function isAtom(ref) {
return ref && ref.resource && ref.resource.kind === "atom";
}
function atomStore(root) {
const owner = root && root.nodeType === 1 ? root : document.documentElement;
let store = atomStores.get(owner);
if (!store) {
store = new Map();
atomStores.set(owner, store);
}
return store;
}
function atomValue(root, id) {
return atomStore(rootOf(root) || root || roots()[0]).get(String(id));
}
function keyedTarget(scope, id, key) {
return firstElement(scope, (el) => attrEquals(el, "data-hemx-key", key) && withinGeneratedResource(el, scope, id));
}
@@ -581,6 +563,37 @@
else target.textContent = payload.value;
}
function morphChildren(target, html) {
const nodes = fragmentNodes({ kind: "html", value: html }, null, null);
preserveIslands(target, nodes);
target.replaceChildren(...nodes);
}
function preserveIslands(current, incomingNodes) {
const islands = new Map();
islandElements(current).forEach((island) => islands.set(island.getAttribute(ISLAND), island));
for (const node of incomingNodes) {
islandElements(node).forEach((island) => {
const preserved = islands.get(island.getAttribute(ISLAND));
if (preserved) island.replaceWith(preserved);
});
}
}
function islandElements(root) {
const islands = [];
const queue = [root];
while (queue.length) {
const node = queue.shift();
if (node.nodeType === 1 && node.hasAttribute(ISLAND)) {
islands.push(node);
continue;
}
for (let child = node.firstElementChild; child; child = child.nextElementSibling) queue.push(child);
}
return islands;
}
function replacePayload(target, payload, key, resourceId) {
const nodes = fragmentNodes(payload, key, resourceId);
if (nodes.length) target.replaceWith(...nodes);
@@ -679,8 +692,11 @@
}
function bindRoot(root) {
if (boundRoots.has(root)) return;
boundRoots.add(root);
["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => {
root.addEventListener(name, (event) => {
if (rootOf(event.target) !== root) return;
if (name === "dragstart") {
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-key"));
if (item) {
@@ -734,8 +750,6 @@
schedule(el, name, event.submitter || el);
});
});
bindPolling(root);
bindRevealed(root);
}
function schedule(el, eventName, source = el) {
@@ -754,6 +768,7 @@
function bindPolling(root) {
forEachElement(root, (el) => {
if (rootOf(el) !== root) return;
if ((!el.hasAttribute("data-hemx-every") && !el.hasAttribute("data-hemx-interval")) || everyTimers.has(el)) return;
const eventName = el.hasAttribute("data-hemx-interval") ? "interval" : "every";
const ms = duration(el.getAttribute("data-hemx-interval") || el.getAttribute("data-hemx-every"));
@@ -776,6 +791,7 @@
function bindRevealed(root) {
let observers = revealObservers.get(root);
forEachElement(root, (el) => {
if (rootOf(el) !== root) return;
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
if (typeof IntersectionObserver === "undefined") {
revealed.add(el);
@@ -866,26 +882,6 @@
return Number(match[1]) * (match[2] === "s" ? 1000 : 1);
}
function bootstrapState(root) {
const encoded = root.getAttribute(STATE);
if (!encoded) return;
try {
const store = atomStore(root);
for (const atom of decodeAtomState(encoded)) store.set(String(atom.id), atom.bytes);
} catch (error) {
atomStores.delete(root);
emit(root, "hemx:state-error", String(error));
}
}
function decodeAtomState(encoded) {
const bytes = base64UrlBytes(encoded);
const d = postcardDecoder(bytes);
const atoms = d.vec(() => ({ id: d.varint(), bytes: d.bytes() }));
if (!d.done()) throw new Error("trailing hemx state bytes");
return atoms;
}
function base64UrlBytes(encoded) {
if (typeof encoded !== "string" || encoded.length > Math.ceil(MAX_WIRE_BYTES * 4 / 3) + 4) {
throw new Error(`encoded hemx state exceeds ${MAX_WIRE_BYTES} bytes`);
@@ -906,37 +902,6 @@
return value;
}
function postcardDecoder(bytes) {
if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx state exceeds ${MAX_WIRE_BYTES} bytes`);
let offset = 0;
const need = (len) => {
boundedLength(len, bytes.length - offset, "hemx state field");
const end = offset + len;
if (end > bytes.length) throw new Error("truncated hemx state");
const slice = bytes.subarray(offset, end);
offset = end;
return slice;
};
const varint = () => {
let value = 0;
for (let index = 0; index < 5; index += 1) {
const byte = need(1)[0];
if (index === 4 && byte > 0x0f) throw new Error("oversized hemx state varint");
value += (byte & 0x7f) * (2 ** (index * 7));
if ((byte & 0x80) === 0) return value >>> 0;
}
throw new Error("oversized hemx state varint");
};
const bytesField = () => need(boundedLength(varint(), MAX_WIRE_FIELD_BYTES, "hemx state bytes"));
const vec = (read) => {
const length = boundedLength(varint(), MAX_WIRE_ITEMS, "hemx state vector");
const values = [];
for (let index = 0; index < length; index += 1) values.push(read());
return values;
};
return { varint, bytes: bytesField, vec, done: () => offset === bytes.length };
}
function decoder(buffer) {
const bytes = new Uint8Array(buffer);
if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx batch exceeds ${MAX_WIRE_BYTES} bytes`);
@@ -1049,6 +1014,17 @@
}
}
function cleanupRemovedFragment(node) {
if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node);
descendantRoots(node).forEach(cleanupRemovedRoot);
if (node.hasAttribute("data-hemx-every") || node.hasAttribute("data-hemx-interval")) stopPolling(node);
stopDescendantPolling(node);
revealed.delete(node);
forEachElement(node, (element) => {
if (element.hasAttribute("data-hemx-revealed")) revealed.delete(element);
});
}
function cleanupRemovedRoot(root) {
const source = sseSources.get(root);
if (source) source.close();
@@ -1060,6 +1036,7 @@
if (observers) observers.forEach((observer) => observer.disconnect());
revealObservers.delete(root);
stopDescendantPolling(root);
boundRoots.delete(root);
}
function rebindRevealed(resetDispatched) {
@@ -1080,42 +1057,36 @@
if (event.persisted) rebindRevealed(true);
}
function bindEnhancedRoot(root) {
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
bindAdapter(root, "events", () => bindRoot(root));
bindAdapter(root, "sse", () => bindSse(root));
bindAdapter(root, "websocket", () => bindWebSocket(root));
bindAdapter(root, "polling", () => bindPolling(root));
bindAdapter(root, "revealed", () => bindRevealed(root));
}
function bindAdapter(root, name, bind) {
try {
bind();
} catch (error) {
showError(root, error);
emit(root, `hemx:${name}-error`, String(error));
}
}
function start() {
roots().forEach((root) => {
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
try {
bootstrapState(root);
} catch (error) {
emit(root, "hemx:state-error", String(error));
}
try {
bindRoot(root);
} catch (error) {
emit(root, "hemx:bind-error", String(error));
}
try {
bindSse(root);
} catch (error) {
emit(root, "hemx:sse-error", String(error));
}
try {
bindWebSocket(root);
} catch (error) {
showError(root, error);
emit(root, "hemx:ws-error", String(error));
}
});
roots().forEach(bindEnhancedRoot);
new MutationObserver((records) => {
records.forEach((record) => {
record.removedNodes.forEach((node) => {
if (!(node instanceof Element)) return;
if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node);
descendantRoots(node).forEach(cleanupRemovedRoot);
cleanupRemovedFragment(node);
});
record.addedNodes.forEach((node) => {
if (!(node instanceof Element)) return;
if (node.hasAttribute(ROOT)) bindRoot(node);
descendantRoots(node).forEach(bindRoot);
if (node.hasAttribute(ROOT)) bindEnhancedRoot(node);
descendantRoots(node).forEach(bindEnhancedRoot);
const owner = rootOf(node.parentElement);
if (owner) {
bindPolling(owner);
@@ -1149,8 +1120,6 @@
applyHtml,
applyBatch,
decodeBatch,
atomValue,
decodeAtomState,
});
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);