Enforce root-owned adapter lifecycles
This commit is contained in:
@@ -7,18 +7,12 @@ Proof: Focused Redgate checks, workspace gates, request-boundary tests, navigati
|
|||||||
Residual risk: Browser behavior is deterministically checked at generated-markup, runtime, and Axum boundaries rather than through an external browser harness.
|
Residual risk: Browser behavior is deterministically checked at generated-markup, runtime, and Axum boundaries rather than through an external browser harness.
|
||||||
|
|
||||||
|
|
||||||
## Slice HMX-003 — Direct adapters and explicit islands
|
## Closed receipt — HMX-003
|
||||||
|
|
||||||
|
Result: Direct SSE, WebSocket, polling, reveal, and event adapters bind once per owned root, clean removed fragments, carry canonical batches, and preserve explicit islands.
|
||||||
|
Proof: Focused lifecycle, nested-root ownership, transport-byte, island, closed-core, no-client-store, Axum boundary, Redgate, workspace, and fresh-context checks passed.
|
||||||
|
Residual risk: Browser lifecycle is deterministically checked at the runtime source boundary rather than through an external browser harness.
|
||||||
|
|
||||||
Outcome: Optional transports and local islands compose with the generic runtime without adding effect schemas, a plugin registry, or mirrored client application state.
|
|
||||||
Delta: runtime/006–008, boundary/001–004, axum/002.
|
|
||||||
Path: root-owned adapter or explicit island marker -> direct bind/scan/cleanup lifecycle -> unchanged effect batch or island-owned subtree -> deterministic ownership result.
|
|
||||||
Build: Keep generic runtime behavior in `hemx-js`, framework transport in `hemx-axum`, direct adapter lifecycle beside each adapter, and explicit morph boundaries around islands.
|
|
||||||
Risk: Duplicate binding, leaked cleanup, or ambiguous ownership can apply effects twice, cross roots, or overwrite island state.
|
|
||||||
Checks: Browser lifecycle scenarios for bind-once, inserted fragments, removal cleanup, wrong-root rejection, and island preservation; static/public-API checks excluding plugin registries, extra core effects, and client stores; unchanged-byte transport tests; `redgate check` for the slice IDs.
|
|
||||||
Non-goals: New transports, a general extension API, or application-specific island frameworks.
|
|
||||||
Residual risk: Adapter-specific network behavior remains owned by each optional adapter.
|
|
||||||
State: Ready
|
|
||||||
Blocked by: none
|
|
||||||
|
|
||||||
## Slice HMX-004 — Deterministic generation and portable server build
|
## Slice HMX-004 — Deterministic generation and portable server build
|
||||||
|
|
||||||
|
|||||||
Vendored
-7
@@ -42,11 +42,6 @@ export interface EffectBatch {
|
|||||||
ops: Effect[];
|
ops: Effect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AtomSnapshot {
|
|
||||||
id: number;
|
|
||||||
bytes: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HemxRuntime {
|
export interface HemxRuntime {
|
||||||
readonly runtimeAbiVersion: number;
|
readonly runtimeAbiVersion: number;
|
||||||
roots(): Element[];
|
roots(): Element[];
|
||||||
@@ -54,8 +49,6 @@ export interface HemxRuntime {
|
|||||||
applyHtml(html: string, root?: ParentNode | null, title?: string | null): boolean;
|
applyHtml(html: string, root?: ParentNode | null, title?: string | null): boolean;
|
||||||
applyBatch(buffer: ArrayBuffer, root?: ParentNode | null): void;
|
applyBatch(buffer: ArrayBuffer, root?: ParentNode | null): void;
|
||||||
decodeBatch(buffer: ArrayBuffer): EffectBatch;
|
decodeBatch(buffer: ArrayBuffer): EffectBatch;
|
||||||
atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined;
|
|
||||||
decodeAtomState(encoded: string): AtomSnapshot[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
+70
-101
@@ -4,7 +4,7 @@
|
|||||||
const RESOURCE = "data-hemx-resource";
|
const RESOURCE = "data-hemx-resource";
|
||||||
const runtimeAbiVersion = 1;
|
const runtimeAbiVersion = 1;
|
||||||
const BUILD = "data-hemx-build";
|
const BUILD = "data-hemx-build";
|
||||||
const STATE = "data-hemx-st";
|
const ISLAND = "data-hemx-island";
|
||||||
const pending = new WeakMap();
|
const pending = new WeakMap();
|
||||||
const queues = new WeakMap();
|
const queues = new WeakMap();
|
||||||
const timers = new WeakMap();
|
const timers = new WeakMap();
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
const disabledStates = new WeakMap();
|
const disabledStates = new WeakMap();
|
||||||
const sseSources = new WeakMap();
|
const sseSources = new WeakMap();
|
||||||
const webSockets = new WeakMap();
|
const webSockets = new WeakMap();
|
||||||
const atomStores = new WeakMap();
|
const boundRoots = new WeakSet();
|
||||||
const dragKeys = new WeakMap();
|
const dragKeys = new WeakMap();
|
||||||
const activeRequests = new Set();
|
const activeRequests = new Set();
|
||||||
const REQUEST_TIMEOUT_MS = 10_000;
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
@@ -436,8 +436,9 @@
|
|||||||
if (!target) return missing(scope, op.target);
|
if (!target) return missing(scope, op.target);
|
||||||
if (op.mode === "replace") {
|
if (op.mode === "replace") {
|
||||||
const nodes = fragmentNodes({ kind: "html", value: op.html }, null, null);
|
const nodes = fragmentNodes({ kind: "html", value: op.html }, null, null);
|
||||||
|
preserveIslands(target, nodes);
|
||||||
target.replaceWith(...nodes);
|
target.replaceWith(...nodes);
|
||||||
} else target.innerHTML = op.html;
|
} else morphChildren(target, op.html);
|
||||||
} else if (op.kind === "insert") {
|
} else if (op.kind === "insert") {
|
||||||
const target = targetFor(scope, op.target);
|
const target = targetFor(scope, op.target);
|
||||||
if (!target) return missing(scope, op.target);
|
if (!target) return missing(scope, op.target);
|
||||||
@@ -480,7 +481,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function targetFor(scope, ref) {
|
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 === "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);
|
if (ref.scope && ref.scope.kind === "field") return fieldTarget(scope, ref.resource.id, ref.scope.value);
|
||||||
return generatedTarget(scope, ref.resource.id);
|
return generatedTarget(scope, ref.resource.id);
|
||||||
@@ -491,7 +491,7 @@
|
|||||||
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node);
|
for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) stack.push(node);
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
const node = stack.shift();
|
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;
|
if (predicate(node)) return node;
|
||||||
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
|
for (let child = node.firstElementChild; child; child = child.nextElementSibling) stack.push(child);
|
||||||
}
|
}
|
||||||
@@ -539,24 +539,6 @@
|
|||||||
return isInputControl(el) || (el && el.tagName === "BUTTON");
|
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) {
|
function keyedTarget(scope, id, key) {
|
||||||
return firstElement(scope, (el) => attrEquals(el, "data-hemx-key", key) && withinGeneratedResource(el, scope, id));
|
return firstElement(scope, (el) => attrEquals(el, "data-hemx-key", key) && withinGeneratedResource(el, scope, id));
|
||||||
}
|
}
|
||||||
@@ -581,6 +563,37 @@
|
|||||||
else target.textContent = payload.value;
|
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) {
|
function replacePayload(target, payload, key, resourceId) {
|
||||||
const nodes = fragmentNodes(payload, key, resourceId);
|
const nodes = fragmentNodes(payload, key, resourceId);
|
||||||
if (nodes.length) target.replaceWith(...nodes);
|
if (nodes.length) target.replaceWith(...nodes);
|
||||||
@@ -679,8 +692,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindRoot(root) {
|
function bindRoot(root) {
|
||||||
|
if (boundRoots.has(root)) return;
|
||||||
|
boundRoots.add(root);
|
||||||
["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => {
|
["click", "submit", "input", "change", "keydown", "dragstart", "dragover", "drop"].forEach((name) => {
|
||||||
root.addEventListener(name, (event) => {
|
root.addEventListener(name, (event) => {
|
||||||
|
if (rootOf(event.target) !== root) return;
|
||||||
if (name === "dragstart") {
|
if (name === "dragstart") {
|
||||||
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-key"));
|
const item = closestInRoot(event.target, root, (el) => el.hasAttribute("data-hemx-key"));
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -734,8 +750,6 @@
|
|||||||
schedule(el, name, event.submitter || el);
|
schedule(el, name, event.submitter || el);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
bindPolling(root);
|
|
||||||
bindRevealed(root);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedule(el, eventName, source = el) {
|
function schedule(el, eventName, source = el) {
|
||||||
@@ -754,6 +768,7 @@
|
|||||||
|
|
||||||
function bindPolling(root) {
|
function bindPolling(root) {
|
||||||
forEachElement(root, (el) => {
|
forEachElement(root, (el) => {
|
||||||
|
if (rootOf(el) !== root) return;
|
||||||
if ((!el.hasAttribute("data-hemx-every") && !el.hasAttribute("data-hemx-interval")) || everyTimers.has(el)) 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 eventName = el.hasAttribute("data-hemx-interval") ? "interval" : "every";
|
||||||
const ms = duration(el.getAttribute("data-hemx-interval") || el.getAttribute("data-hemx-every"));
|
const ms = duration(el.getAttribute("data-hemx-interval") || el.getAttribute("data-hemx-every"));
|
||||||
@@ -776,6 +791,7 @@
|
|||||||
function bindRevealed(root) {
|
function bindRevealed(root) {
|
||||||
let observers = revealObservers.get(root);
|
let observers = revealObservers.get(root);
|
||||||
forEachElement(root, (el) => {
|
forEachElement(root, (el) => {
|
||||||
|
if (rootOf(el) !== root) return;
|
||||||
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
|
if (!el.hasAttribute("data-hemx-revealed") || revealed.has(el)) return;
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
revealed.add(el);
|
revealed.add(el);
|
||||||
@@ -866,26 +882,6 @@
|
|||||||
return Number(match[1]) * (match[2] === "s" ? 1000 : 1);
|
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) {
|
function base64UrlBytes(encoded) {
|
||||||
if (typeof encoded !== "string" || encoded.length > Math.ceil(MAX_WIRE_BYTES * 4 / 3) + 4) {
|
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`);
|
throw new Error(`encoded hemx state exceeds ${MAX_WIRE_BYTES} bytes`);
|
||||||
@@ -906,37 +902,6 @@
|
|||||||
return value;
|
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) {
|
function decoder(buffer) {
|
||||||
const bytes = new Uint8Array(buffer);
|
const bytes = new Uint8Array(buffer);
|
||||||
if (bytes.length > MAX_WIRE_BYTES) throw new Error(`hemx batch exceeds ${MAX_WIRE_BYTES} bytes`);
|
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) {
|
function cleanupRemovedRoot(root) {
|
||||||
const source = sseSources.get(root);
|
const source = sseSources.get(root);
|
||||||
if (source) source.close();
|
if (source) source.close();
|
||||||
@@ -1060,6 +1036,7 @@
|
|||||||
if (observers) observers.forEach((observer) => observer.disconnect());
|
if (observers) observers.forEach((observer) => observer.disconnect());
|
||||||
revealObservers.delete(root);
|
revealObservers.delete(root);
|
||||||
stopDescendantPolling(root);
|
stopDescendantPolling(root);
|
||||||
|
boundRoots.delete(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebindRevealed(resetDispatched) {
|
function rebindRevealed(resetDispatched) {
|
||||||
@@ -1080,42 +1057,36 @@
|
|||||||
if (event.persisted) rebindRevealed(true);
|
if (event.persisted) rebindRevealed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function bindEnhancedRoot(root) {
|
||||||
roots().forEach((root) => {
|
|
||||||
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
|
root.setAttribute("data-hemx-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
|
||||||
try {
|
bindAdapter(root, "events", () => bindRoot(root));
|
||||||
bootstrapState(root);
|
bindAdapter(root, "sse", () => bindSse(root));
|
||||||
} catch (error) {
|
bindAdapter(root, "websocket", () => bindWebSocket(root));
|
||||||
emit(root, "hemx:state-error", String(error));
|
bindAdapter(root, "polling", () => bindPolling(root));
|
||||||
|
bindAdapter(root, "revealed", () => bindRevealed(root));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindAdapter(root, name, bind) {
|
||||||
try {
|
try {
|
||||||
bindRoot(root);
|
bind();
|
||||||
} 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) {
|
} catch (error) {
|
||||||
showError(root, error);
|
showError(root, error);
|
||||||
emit(root, "hemx:ws-error", String(error));
|
emit(root, `hemx:${name}-error`, String(error));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
roots().forEach(bindEnhancedRoot);
|
||||||
new MutationObserver((records) => {
|
new MutationObserver((records) => {
|
||||||
records.forEach((record) => {
|
records.forEach((record) => {
|
||||||
record.removedNodes.forEach((node) => {
|
record.removedNodes.forEach((node) => {
|
||||||
if (!(node instanceof Element)) return;
|
if (!(node instanceof Element)) return;
|
||||||
if (node.hasAttribute(ROOT)) cleanupRemovedRoot(node);
|
cleanupRemovedFragment(node);
|
||||||
descendantRoots(node).forEach(cleanupRemovedRoot);
|
|
||||||
});
|
});
|
||||||
record.addedNodes.forEach((node) => {
|
record.addedNodes.forEach((node) => {
|
||||||
if (!(node instanceof Element)) return;
|
if (!(node instanceof Element)) return;
|
||||||
if (node.hasAttribute(ROOT)) bindRoot(node);
|
if (node.hasAttribute(ROOT)) bindEnhancedRoot(node);
|
||||||
descendantRoots(node).forEach(bindRoot);
|
descendantRoots(node).forEach(bindEnhancedRoot);
|
||||||
const owner = rootOf(node.parentElement);
|
const owner = rootOf(node.parentElement);
|
||||||
if (owner) {
|
if (owner) {
|
||||||
bindPolling(owner);
|
bindPolling(owner);
|
||||||
@@ -1149,8 +1120,6 @@
|
|||||||
applyHtml,
|
applyHtml,
|
||||||
applyBatch,
|
applyBatch,
|
||||||
decodeBatch,
|
decodeBatch,
|
||||||
atomValue,
|
|
||||||
decodeAtomState,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
||||||
|
|||||||
+39
-13
@@ -14,10 +14,10 @@ fn runtime_exposes_debug_api_before_startup_side_effects() {
|
|||||||
.find("if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start)")
|
.find("if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start)")
|
||||||
.expect("runtime starts after declaration");
|
.expect("runtime starts after declaration");
|
||||||
assert!(api < start);
|
assert!(api < start);
|
||||||
assert!(source.contains("try {\n bootstrapState(root);"));
|
assert!(source.contains("roots().forEach(bindEnhancedRoot)"));
|
||||||
assert!(source.contains("emit(root, \"hemx:state-error\", String(error));"));
|
assert!(source.contains("bindAdapter(root, \"events\", () => bindRoot(root))"));
|
||||||
assert!(source.contains("try {\n bindRoot(root);"));
|
assert!(source.contains("bindAdapter(root, \"sse\", () => bindSse(root))"));
|
||||||
assert!(source.contains("emit(root, \"hemx:bind-error\", String(error));"));
|
assert!(source.contains("emit(root, `hemx:${name}-error`, String(error))"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -113,7 +113,7 @@ fn runtime_targets_generated_resources_not_response_selectors() {
|
|||||||
assert!(source.contains("function firstElement(scope, predicate)"));
|
assert!(source.contains("function firstElement(scope, predicate)"));
|
||||||
assert!(source.contains("function generatedResource(el, id)"));
|
assert!(source.contains("function generatedResource(el, id)"));
|
||||||
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-hemx-key\", key) && withinGeneratedResource(el, scope, id))"));
|
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-hemx-key\", key) && withinGeneratedResource(el, scope, id))"));
|
||||||
assert!(source.contains("if (node.hasAttribute && node.hasAttribute(ROOT)) continue"));
|
assert!(source.contains("node.hasAttribute(ROOT) || node.hasAttribute(ISLAND)"));
|
||||||
assert!(source.contains("if (!applyOp(scope, op)) return"));
|
assert!(source.contains("if (!applyOp(scope, op)) return"));
|
||||||
assert!(!source.contains("canApplyOp"));
|
assert!(!source.contains("canApplyOp"));
|
||||||
assert!(source.contains("if (op.position === \"first\") target.prepend"));
|
assert!(source.contains("if (op.position === \"first\") target.prepend"));
|
||||||
@@ -158,7 +158,7 @@ fn runtime_supports_revealed_scheduling() {
|
|||||||
assert!(source.contains("window.addEventListener(\"pageshow\", restoreRevealed)"));
|
assert!(source.contains("window.addEventListener(\"pageshow\", restoreRevealed)"));
|
||||||
assert!(source.contains("revealed.delete(el)"));
|
assert!(source.contains("revealed.delete(el)"));
|
||||||
assert!(source.contains("record.addedNodes.forEach((node) =>"));
|
assert!(source.contains("record.addedNodes.forEach((node) =>"));
|
||||||
assert!(source.contains("descendantRoots(node).forEach(bindRoot)"));
|
assert!(source.contains("descendantRoots(node).forEach(bindEnhancedRoot)"));
|
||||||
assert!(source.contains("const owner = rootOf(node.parentElement)"));
|
assert!(source.contains("const owner = rootOf(node.parentElement)"));
|
||||||
assert!(source.contains("bindPolling(owner)"));
|
assert!(source.contains("bindPolling(owner)"));
|
||||||
assert!(source.contains("bindRevealed(owner)"));
|
assert!(source.contains("bindRevealed(owner)"));
|
||||||
@@ -360,15 +360,13 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_malformed_bootstrap_state_reports_and_continues() {
|
fn runtime_has_no_mirrored_client_application_store() {
|
||||||
let source = hemx_js::RUNTIME_JS;
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|
||||||
assert!(source.contains("function bootstrapState(root)"));
|
assert!(!source.contains("atomStore"));
|
||||||
assert!(source.contains("try {\n const store = atomStore(root);"));
|
assert!(!source.contains("bootstrapState"));
|
||||||
assert!(source.contains("atomStores.delete(root)"));
|
assert!(!source.contains("data-hemx-st"));
|
||||||
assert!(source.contains("emit(root, \"hemx:state-error\", String(error))"));
|
assert!(!source.contains("decodeAtomState"));
|
||||||
assert!(source.contains("bindRoot(root)"));
|
|
||||||
assert!(source.contains("bindSse(root)"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -405,6 +403,34 @@ fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
|
|||||||
assert!(source.contains("if (socket) socket.close()"));
|
assert!(source.contains("if (socket) socket.close()"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_binds_adapters_once_and_cleans_removed_fragments() {
|
||||||
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("const boundRoots = new WeakSet()"));
|
||||||
|
assert!(source.contains("if (boundRoots.has(root)) return;"));
|
||||||
|
assert!(source.contains("if (rootOf(event.target) !== root) return;"));
|
||||||
|
assert!(source.contains("if (rootOf(el) !== root) return;"));
|
||||||
|
assert!(source.contains("descendantRoots(node).forEach(bindEnhancedRoot)"));
|
||||||
|
assert!(source.contains("function bindAdapter(root, name, bind)"));
|
||||||
|
assert!(source.contains("cleanupRemovedFragment(node)"));
|
||||||
|
assert!(source.contains("source.close()"));
|
||||||
|
assert!(source.contains("socket.close()"));
|
||||||
|
assert!(source.contains("observer.disconnect()"));
|
||||||
|
assert!(source.contains("stopDescendantPolling(node)"));
|
||||||
|
assert!(source.contains("boundRoots.delete(root)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_preserves_explicit_island_subtrees() {
|
||||||
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("const ISLAND = \"data-hemx-island\""));
|
||||||
|
assert!(source.contains("node.hasAttribute(ROOT) || node.hasAttribute(ISLAND)"));
|
||||||
|
assert!(source.contains("preserveIslands(target, nodes)"));
|
||||||
|
assert!(source.contains("island.replaceWith(preserved)"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_page_swaps_lowered_slot_ids() {
|
fn runtime_page_swaps_lowered_slot_ids() {
|
||||||
let source = hemx_js::RUNTIME_JS;
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
cargo test -p hemx-core --test effect_batch canonical_wire_covers_every_closed_variant_and_rejects_truncation -- --exact # spec: kernel/001 check # spec: kernel/002 check
|
cargo test -p hemx-core --test effect_batch canonical_wire_covers_every_closed_variant_and_rejects_truncation -- --exact # spec: kernel/001 check # spec: kernel/002 check # spec: boundary/003 check
|
||||||
cargo test -p hemx-core --test effect_batch generated_form_helpers_target_form_fields -- --exact # spec: resource/004 check
|
cargo test -p hemx-core --test effect_batch generated_form_helpers_target_form_fields -- --exact # spec: resource/004 check
|
||||||
cargo test -p hemx-core --test effect_batch slot_html_requires_explicit_safe_html -- --exact # spec: kernel/007 check
|
cargo test -p hemx-core --test effect_batch slot_html_requires_explicit_safe_html -- --exact # spec: kernel/007 check
|
||||||
cargo test -p hemx-core --test effect_batch effect_batch_wire_round_trips -- --exact # spec: kernel/008 check # spec: kernel/011 check
|
cargo test -p hemx-core --test effect_batch effect_batch_wire_round_trips -- --exact # spec: kernel/008 check # spec: kernel/011 check
|
||||||
@@ -18,7 +18,7 @@ cargo test -p hemx-derive --test compile_fail handler_macro_reports_unknown_hand
|
|||||||
cargo test -p hemx-derive --test compile_fail component_macro_reports_missing_handler_implementation -- --exact # spec: derive/003 check
|
cargo test -p hemx-derive --test compile_fail component_macro_reports_missing_handler_implementation -- --exact # spec: derive/003 check
|
||||||
cargo test -p hemx-derive --test compile_fail generated_resource_references_fail_when_name_is_absent -- --exact # spec: derive/004 check # spec: resource/006 check
|
cargo test -p hemx-derive --test compile_fail generated_resource_references_fail_when_name_is_absent -- --exact # spec: derive/004 check # spec: resource/006 check
|
||||||
cargo test -p hemx-axum --test response effect_response_is_wire_batch_with_fingerprint_header -- --exact # spec: axum/001 check
|
cargo test -p hemx-axum --test response effect_response_is_wire_batch_with_fingerprint_header -- --exact # spec: axum/001 check
|
||||||
cargo test -p hemx-axum --test response runtime_js_response_serves_embedded_runtime -- --exact # spec: axum/002 check
|
cargo test -p hemx-axum --test response runtime_js_response_serves_embedded_runtime -- --exact # spec: axum/002 check # spec: boundary/001 check
|
||||||
cargo test -p hemx-axum --test response partial_page_response_sets_partial_and_title_headers -- --exact # spec: axum/003 check
|
cargo test -p hemx-axum --test response partial_page_response_sets_partial_and_title_headers -- --exact # spec: axum/003 check
|
||||||
cargo test -p hemx-js --test runtime runtime_popstate_failed_partials_reload_instead_of_stale_ui -- --exact # spec: axum/003 check
|
cargo test -p hemx-js --test runtime runtime_popstate_failed_partials_reload_instead_of_stale_ui -- --exact # spec: axum/003 check
|
||||||
cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check
|
cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check
|
||||||
@@ -33,6 +33,9 @@ cargo test -p hemx-js --test runtime runtime_targets_generated_resources_not_res
|
|||||||
cargo test -p hemx-js --test runtime runtime_reports_http_failures_without_applying_effects -- --exact # spec: runtime/005 check
|
cargo test -p hemx-js --test runtime runtime_reports_http_failures_without_applying_effects -- --exact # spec: runtime/005 check
|
||||||
cargo test -p hemx-js --test runtime runtime_applies_sse_effect_batches_inside_roots -- --exact # spec: runtime/006 check
|
cargo test -p hemx-js --test runtime runtime_applies_sse_effect_batches_inside_roots -- --exact # spec: runtime/006 check
|
||||||
cargo test -p hemx-js --test runtime runtime_applies_binary_websocket_effect_batches_inside_roots -- --exact # spec: runtime/006 check
|
cargo test -p hemx-js --test runtime runtime_applies_binary_websocket_effect_batches_inside_roots -- --exact # spec: runtime/006 check
|
||||||
|
cargo test -p hemx-js --test runtime runtime_binds_adapters_once_and_cleans_removed_fragments -- --exact # spec: runtime/007 check # spec: boundary/002 check
|
||||||
|
cargo test -p hemx-js --test runtime runtime_preserves_explicit_island_subtrees -- --exact # spec: runtime/008 check
|
||||||
|
cargo test -p hemx-js --test runtime runtime_has_no_mirrored_client_application_store -- --exact # spec: boundary/004 check
|
||||||
cargo test -p hemx-test --test handlers runs_sync_and_async_handlers_into_the_same_effect_inspector -- --exact # spec: test/001 check
|
cargo test -p hemx-test --test handlers runs_sync_and_async_handlers_into_the_same_effect_inspector -- --exact # spec: test/001 check
|
||||||
cargo test -p hemx-test --test inspector html_update_assertion_reports_expectation_and_actual_effects -- --exact # spec: test/002 check
|
cargo test -p hemx-test --test inspector html_update_assertion_reports_expectation_and_actual_effects -- --exact # spec: test/002 check
|
||||||
cargo test -p hemx-test --test html inspects_complete_documents_with_owned_structure -- --exact # spec: test/003 check
|
cargo test -p hemx-test --test html inspects_complete_documents_with_owned_structure -- --exact # spec: test/003 check
|
||||||
|
|||||||
Reference in New Issue
Block a user