From 9e12b1eb468f3b66bc08ac3ae6aac18ac001ed58 Mon Sep 17 00:00:00 2001 From: tmk241 Date: Tue, 1 Sep 2026 09:43:38 +0200 Subject: [PATCH] Enforce root-owned adapter lifecycles --- PLAN.md | 16 ++-- hemx-js/runtime/hemx.d.ts | 7 -- hemx-js/runtime/hemx.js | 181 ++++++++++++++++---------------------- hemx-js/tests/runtime.rs | 52 ++++++++--- tests/redgate_test.sh | 7 +- 5 files changed, 124 insertions(+), 139 deletions(-) diff --git a/PLAN.md b/PLAN.md index 36fe61a..b8361f9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/hemx-js/runtime/hemx.d.ts b/hemx-js/runtime/hemx.d.ts index 4754952..585b046 100644 --- a/hemx-js/runtime/hemx.d.ts +++ b/hemx-js/runtime/hemx.d.ts @@ -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 { diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index fe4d5d1..67bb973 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -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); diff --git a/hemx-js/tests/runtime.rs b/hemx-js/tests/runtime.rs index 2fa68cb..61ead1e 100644 --- a/hemx-js/tests/runtime.rs +++ b/hemx-js/tests/runtime.rs @@ -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; diff --git a/tests/redgate_test.sh b/tests/redgate_test.sh index d3d2fe3..2fbf69a 100755 --- a/tests/redgate_test.sh +++ b/tests/redgate_test.sh @@ -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