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
+5 -11
View File
@@ -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.
## 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
-7
View File
@@ -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
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);
+39 -13
View File
@@ -14,10 +14,10 @@ fn runtime_exposes_debug_api_before_startup_side_effects() {
.find("if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start)")
.expect("runtime starts after declaration");
assert!(api < start);
assert!(source.contains("try {\n bootstrapState(root);"));
assert!(source.contains("emit(root, \"hemx:state-error\", String(error));"));
assert!(source.contains("try {\n bindRoot(root);"));
assert!(source.contains("emit(root, \"hemx:bind-error\", String(error));"));
assert!(source.contains("roots().forEach(bindEnhancedRoot)"));
assert!(source.contains("bindAdapter(root, \"events\", () => bindRoot(root))"));
assert!(source.contains("bindAdapter(root, \"sse\", () => bindSse(root))"));
assert!(source.contains("emit(root, `hemx:${name}-error`, String(error))"));
}
#[test]
@@ -113,7 +113,7 @@ 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-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("canApplyOp"));
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("revealed.delete(el)"));
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("bindPolling(owner)"));
assert!(source.contains("bindRevealed(owner)"));
@@ -360,15 +360,13 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() {
}
#[test]
fn runtime_malformed_bootstrap_state_reports_and_continues() {
fn runtime_has_no_mirrored_client_application_store() {
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("function bootstrapState(root)"));
assert!(source.contains("try {\n const store = atomStore(root);"));
assert!(source.contains("atomStores.delete(root)"));
assert!(source.contains("emit(root, \"hemx:state-error\", String(error))"));
assert!(source.contains("bindRoot(root)"));
assert!(source.contains("bindSse(root)"));
assert!(!source.contains("atomStore"));
assert!(!source.contains("bootstrapState"));
assert!(!source.contains("data-hemx-st"));
assert!(!source.contains("decodeAtomState"));
}
#[test]
@@ -405,6 +403,34 @@ fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
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]
fn runtime_page_swaps_lowered_slot_ids() {
let source = hemx_js::RUNTIME_JS;
+5 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh
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 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
@@ -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 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 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-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
@@ -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_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_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 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