Enforce root-owned adapter lifecycles
This commit is contained in:
Vendored
-7
@@ -42,11 +42,6 @@ export interface EffectBatch {
|
||||
ops: Effect[];
|
||||
}
|
||||
|
||||
export interface AtomSnapshot {
|
||||
id: number;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface HemxRuntime {
|
||||
readonly runtimeAbiVersion: number;
|
||||
roots(): Element[];
|
||||
@@ -54,8 +49,6 @@ export interface HemxRuntime {
|
||||
applyHtml(html: string, root?: ParentNode | null, title?: string | null): boolean;
|
||||
applyBatch(buffer: ArrayBuffer, root?: ParentNode | null): void;
|
||||
decodeBatch(buffer: ArrayBuffer): EffectBatch;
|
||||
atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined;
|
||||
decodeAtomState(encoded: string): AtomSnapshot[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
+75
-106
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user