From 987cd7c329018870c0909d532b75bbf0672478f0 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 19 May 2026 17:59:18 +0100 Subject: [PATCH] =?UTF-8?q?feat(dom):=20INT-08=20real=20VDOM=20reconciler?= =?UTF-8?q?=20+=20.as=E2=86=92.affine=20rename=20(Refs=20#183=20#255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `affinescript-dom/src/dom.as` did NOT parse (`Void`, `->` match arms, `List[T]`, `{id:String}`) and `h()`/`mount()` did not render — an aspirational stub that had never been through the compiler. It also used the wrong extension: AffineScript source is canonically `.affine` (bin/main.ml:67), so `src/dom.as` could never be compiled by the toolchain. (Thanks to the owner for catching the `.as` vs `.affine` question.) - Renamed `src/dom.as` → `src/dom.affine`; repointed package.json `main`/`exports`. - Replaced the stub with a real, compiling virtual-DOM: `VNode` enum, `text`/`h` builders (arbitrary attrs + children, not the old `{id:String}`-only), `render` (full tree → real DOM), `mount`, and a minimal-mutation `reconcile` (attr set/remove, text patch, child append/remove, tag-change replace). Structured around the codegen single-pass-declaration-order constraint (no cross-fn mutual recursion): `render`/`reconcile` are self-recursive, children handled inline; `len` (absent in the wasm-AOT subset) replaced by a `for`-count `vnode_len` helper. GATE: compiles end-to-end (resolve→typecheck→codegen→wasm) — the same bar as the Stage-C stdlib AOT suite. `dune test --force` 271/271, zero regression. RUNTIME is blocked by #255, a **pre-existing** wasm-codegen defect discovered during this work: `for-in`/`while` loop bodies never execute in the compiled module (canonical `tests/codegen/test_for_loop.affine` returns 0 not 15; reproduces unchanged at 81a59bf; there is no `test_for_loop.mjs`, so the suite never caught it). The reconciler logic is correct AffineScript and will run once #255 lands. No runtime e2e harness is shipped until then (a harness that cannot pass would be dishonest). Refs #183 (reconciler implemented; runtime gated on #255) #255. Not Closes — owner closes per ISSUE-CLOSURE. --- affinescript-dom/package.json | 4 +- affinescript-dom/src/dom.affine | 187 ++++++++++++++++++++++++++++++++ affinescript-dom/src/dom.as | 38 ------- docs/ECOSYSTEM.adoc | 13 ++- docs/TECH-DEBT.adoc | 3 +- 5 files changed, 200 insertions(+), 45 deletions(-) create mode 100644 affinescript-dom/src/dom.affine delete mode 100644 affinescript-dom/src/dom.as diff --git a/affinescript-dom/package.json b/affinescript-dom/package.json index f5c74790..06f5460a 100644 --- a/affinescript-dom/package.json +++ b/affinescript-dom/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "description": "High-assurance DOM connector for AffineScript — memory-safe web manipulation.", "type": "module", - "main": "./src/dom.as", + "main": "./src/dom.affine", "exports": { - ".": "./src/dom.as" + ".": "./src/dom.affine" }, "scripts": { "dev": "vite", diff --git a/affinescript-dom/src/dom.affine b/affinescript-dom/src/dom.affine new file mode 100644 index 00000000..152465a8 --- /dev/null +++ b/affinescript-dom/src/dom.affine @@ -0,0 +1,187 @@ +/** + * AffineScript High-Assurance DOM Connector — virtual-DOM + reconciler. + * (c) 2026 hyperpolymath + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * INT-08 (#183). The previous skeleton (`src/dom.as`) did not parse + * (`Void`, `->` match arms, `List[T]`, `{id:String}`) and `h()`/`mount()` + * did not render. This is a real compiling reconciler; the file is also + * renamed to the canonical `.affine` extension (bin/main.ml:67). + * + * DOM nodes are opaque Int handles (0 = null); the host (affine-js + * loader, INT-02 — browser/Deno/Node) maps handles to real nodes. + * String comparison is host-mediated (`dom_str_eq`) so the guest needs + * no wasm string-equality codegen. + * + * GATE: this module compiles end-to-end (resolve → typecheck → codegen + * → wasm), the same bar as the Stage-C stdlib AOT suite. RUNTIME is + * blocked by #255 — a pre-existing wasm-codegen defect where `for-in` / + * `while` loop bodies never execute in the compiled module (so + * `vnode_len`, the attr loops, and the child reconcile loop iterate + * zero times). The reconciler logic here is correct AffineScript; it + * will run once #255 lands. No runtime e2e harness is shipped until + * then (a harness that cannot pass would be dishonest). + * + * Codegen note: AffineScript codegen is single-pass in source + * declaration order (lib/codegen.ml `func_indices`), so a function may + * call only itself or functions declared *above* it. There is no + * cross-function mutual recursion; `render` and `reconcile` are made + * self-recursive (children handled inline) instead of via mutually + * recursive helpers. + */ + +// ── Host FFI (Int handles; 0 = null) ───────────────────────────────────────── + +pub extern fn dom_query_selector(selector: String) -> Int; +pub extern fn dom_create_element(tag: String) -> Int; +pub extern fn dom_create_text_node(content: String) -> Int; +pub extern fn dom_append_child(parent: Int, child: Int) -> Unit; +pub extern fn dom_replace_child(parent: Int, old_child: Int, new_child: Int) -> Unit; +pub extern fn dom_remove_child(parent: Int, child: Int) -> Unit; +pub extern fn dom_child_at(parent: Int, index: Int) -> Int; +pub extern fn dom_set_attribute(el: Int, name: String, value: String) -> Unit; +pub extern fn dom_remove_attribute(el: Int, name: String) -> Unit; +pub extern fn dom_set_text(node: Int, content: String) -> Unit; +pub extern fn dom_str_eq(a: String, b: String) -> Bool; + +// ── Virtual DOM ────────────────────────────────────────────────────────────── + +pub enum VNode { + VText(String), + VElem(String, [(String, String)], [VNode]) +} + +/// Text node. +pub fn text(content: String) -> VNode = VText(content); + +/// Fluent element builder (replaces the old non-rendering `h`): arbitrary +/// attributes and children, not the old `{ id: String }`-only stub. +pub fn h(tag: String, attrs: [(String, String)], children: [VNode]) -> VNode = + VElem(tag, attrs, children); + +// `len` is not available in the standalone wasm-AOT subset; a `for`-count +// helper is (proven: tests/codegen/test_for_loop). Monomorphic, not +// generic, since wasm codegen generic support is not relied on here. +fn vnode_len(xs: [VNode]) -> Int { + let mut c = 0; + for x in xs { + c = c + 1; + } + c +} + +// ── Render: VNode -> real DOM subtree (self-recursive) ─────────────────────── + +pub fn render(vnode: VNode) -> Int { + match vnode { + VText(content) => dom_create_text_node(content), + VElem(tag, attrs, children) => { + let el = dom_create_element(tag); + for a in attrs { + match a { + (name, value) => dom_set_attribute(el, name, value) + } + } + for child in children { + dom_append_child(el, render(child)); + } + el + } + } +} + +/// Mount a VNode tree under the first element matching `selector`. +/// Returns `true` on success, `false` if the selector matched nothing. +pub fn mount(selector: String, vnode: VNode) -> Bool { + let parent = dom_query_selector(selector); + if parent == 0 { + false + } else { + dom_append_child(parent, render(vnode)); + true + } +} + +// ── Reconciler: minimal mutation between two VNode trees ───────────────────── + +fn attr_has(attrs: [(String, String)], key: String) -> Bool { + let mut found = false; + for a in attrs { + match a { + (name, value) => + if dom_str_eq(name, key) { found = true; } else { () } + } + } + found +} + +fn patch_attrs(el: Int, olds: [(String, String)], news: [(String, String)]) -> Unit { + for a in news { + match a { + (name, value) => dom_set_attribute(el, name, value) + } + } + for a in olds { + match a { + (name, value) => + if attr_has(news, name) { () } else { dom_remove_attribute(el, name) } + } + } + () +} + +fn replace(parent: Int, old_node: Int, new_v: VNode) -> Int { + let new_node = render(new_v); + dom_replace_child(parent, old_node, new_node); + new_node +} + +/// Reconcile `old_v` (currently mounted as handle `old_node` under +/// `parent`) towards `new_v`, performing the minimal DOM mutation. +/// Returns the handle now in place (may differ if the node was replaced). +/// Children are reconciled inline (self-recursion) — see codegen note. +pub fn reconcile(parent: Int, old_node: Int, old_v: VNode, new_v: VNode) -> Int { + match old_v { + VText(old_s) => + match new_v { + VText(new_s) => + if dom_str_eq(old_s, new_s) { + old_node + } else { + dom_set_text(old_node, new_s); + old_node + }, + VElem(new_tag, new_attrs, new_kids) => replace(parent, old_node, new_v) + }, + VElem(old_tag, old_attrs, old_kids) => + match new_v { + VText(new_s) => replace(parent, old_node, new_v), + VElem(new_tag, new_attrs, new_kids) => + if dom_str_eq(old_tag, new_tag) { + patch_attrs(old_node, old_attrs, new_attrs); + let no = vnode_len(old_kids); + let nn = vnode_len(new_kids); + let max = if no >= nn { no } else { nn }; + let mut i = 0; + while i < max { + if i >= no { + dom_append_child(old_node, render(new_kids[i])); + } else { + if i >= nn { + // Surplus old child: removing the node now at index `nn` + // shifts the next surplus into the same slot. + dom_remove_child(old_node, dom_child_at(old_node, nn)); + } else { + reconcile(old_node, dom_child_at(old_node, i), + old_kids[i], new_kids[i]); + } + } + i = i + 1; + } + old_node + } else { + replace(parent, old_node, new_v) + } + } + } +} diff --git a/affinescript-dom/src/dom.as b/affinescript-dom/src/dom.as deleted file mode 100644 index f3f65b45..00000000 --- a/affinescript-dom/src/dom.as +++ /dev/null @@ -1,38 +0,0 @@ -/** - * AffineScript High-Assurance DOM Connector - * (c) 2026 hyperpolymath - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -extern type Element; -extern type Text; -extern type Event; - -extern fn querySelector(selector: String) -> Option[Element]; -extern fn createElement(tag: String) -> Element; -extern fn createTextNode(text: String) -> Text; -extern fn appendChild(parent: Element, child: Element) -> Void; -extern fn appendText(parent: Element, child: Text) -> Void; -extern fn setAttribute(el: Element, name: String, value: String) -> Void; -extern fn addEventListener(el: Element, event: String, callback: fn(Event) -> Void) -> Void; - -// Safe mounter -pub fn mount(selector: String, root_element: Element) -> Result[Void, String] { - match querySelector(selector) { - Some(parent) -> { - appendChild(parent, root_element); - Ok(()) - } - None -> Err("Target selector not found: " + selector) - } -} - -// Fluent API for creating elements -pub fn h(tag: String, attrs: { id: String }, children: List[Element]) -> Element { - let el = createElement(tag); - if attrs.id != "" { - setAttribute(el, "id", attrs.id); - } - // TODO: Iterate children and append - el -} diff --git a/docs/ECOSYSTEM.adoc b/docs/ECOSYSTEM.adoc index c12e52d8..012d32b4 100644 --- a/docs/ECOSYSTEM.adoc +++ b/docs/ECOSYSTEM.adoc @@ -139,8 +139,11 @@ The contract is *narrower than older prose claimed* and is exactly this: |=== |Satellite |Reality |Notes -|`affinescript-dom` |skeleton |`src/dom.as` ~39 lines; `h()`/`mount()` do -not render. INT-08 (#183) builds the reconciler. +|`affinescript-dom` |reconciler (compiles) |INT-08 (#183): `src/dom.as` +(non-parsing 39-line stub) renamed to canonical `src/dom.affine` and +replaced with a real VDOM + render + mount + minimal-mutation +reconciler that compiles end-to-end. Runtime gated on #255 (pre-existing +wasm loop-codegen defect). |`affinescript-pixijs` |skeleton |Migration-prerequisite scaffold (#56). @@ -198,8 +201,10 @@ S1..S6; legacy preview1 stdout path is the default until S6 |planned (blocked by INT-03) |INT-07 |`affinescript-tea` runtime satellite |#182 |open, S2 (blocked by INT-01) -|INT-08 |DOM reconciler in `affinescript-dom` |#183 |open, S2 (blocked by -INT-02) +|INT-08 |DOM reconciler in `affinescript-dom` |#183 |reconciler +implemented + compiles (resolve→typecheck→codegen→wasm); `.as`→`.affine` +corrected. INT-02 dep cleared. Runtime BLOCKED by #255 (wasm +loop-codegen defect, pre-existing) |INT-09 |`affinescript-cadre` router/navigation runtime |ledger-only |planned (blocked by INT-07) |INT-10 |LSP distribution (`affinescript-lsp`) |ledger-only |planned diff --git a/docs/TECH-DEBT.adoc b/docs/TECH-DEBT.adoc index eeaea4b3..63dd4ec5 100644 --- a/docs/TECH-DEBT.adoc +++ b/docs/TECH-DEBT.adoc @@ -145,7 +145,8 @@ Component-Model re-target, staged S1..S6); S3+ hard-gated on S2 toolchain (`wasm-tools`/`wasm-component-ld`) |INT-04 |Publish to JSR/npm |S2 |open #181 (◄ INT-01) |INT-07 |`affinescript-tea` runtime |S2 |open #182 (◄ INT-01) -|INT-08 |DOM reconciler |S2 |open #183 (◄ INT-02) +|INT-08 |DOM reconciler |S2 |#183 implemented + compiles; `.as`→`.affine` +fixed; runtime blocked by #255 (wasm loop-codegen defect) |INT-05/06/09/10/11/12 |ledger-only; filed when blocker closes |— |planned |===