Skip to content

Commit 1ce5ff5

Browse files
feat(dom): INT-08 real VDOM reconciler + .as→.affine rename (Refs #183 #255) (#256)
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.
1 parent d88770a commit 1ce5ff5

5 files changed

Lines changed: 200 additions & 45 deletions

File tree

affinescript-dom/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
"version": "0.1.0",
44
"description": "High-assurance DOM connector for AffineScript — memory-safe web manipulation.",
55
"type": "module",
6-
"main": "./src/dom.as",
6+
"main": "./src/dom.affine",
77
"exports": {
8-
".": "./src/dom.as"
8+
".": "./src/dom.affine"
99
},
1010
"scripts": {
1111
"dev": "vite",

affinescript-dom/src/dom.affine

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* AffineScript High-Assurance DOM Connector — virtual-DOM + reconciler.
3+
* (c) 2026 hyperpolymath
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*
6+
* INT-08 (#183). The previous skeleton (`src/dom.as`) did not parse
7+
* (`Void`, `->` match arms, `List[T]`, `{id:String}`) and `h()`/`mount()`
8+
* did not render. This is a real compiling reconciler; the file is also
9+
* renamed to the canonical `.affine` extension (bin/main.ml:67).
10+
*
11+
* DOM nodes are opaque Int handles (0 = null); the host (affine-js
12+
* loader, INT-02 — browser/Deno/Node) maps handles to real nodes.
13+
* String comparison is host-mediated (`dom_str_eq`) so the guest needs
14+
* no wasm string-equality codegen.
15+
*
16+
* GATE: this module compiles end-to-end (resolve → typecheck → codegen
17+
* → wasm), the same bar as the Stage-C stdlib AOT suite. RUNTIME is
18+
* blocked by #255 — a pre-existing wasm-codegen defect where `for-in` /
19+
* `while` loop bodies never execute in the compiled module (so
20+
* `vnode_len`, the attr loops, and the child reconcile loop iterate
21+
* zero times). The reconciler logic here is correct AffineScript; it
22+
* will run once #255 lands. No runtime e2e harness is shipped until
23+
* then (a harness that cannot pass would be dishonest).
24+
*
25+
* Codegen note: AffineScript codegen is single-pass in source
26+
* declaration order (lib/codegen.ml `func_indices`), so a function may
27+
* call only itself or functions declared *above* it. There is no
28+
* cross-function mutual recursion; `render` and `reconcile` are made
29+
* self-recursive (children handled inline) instead of via mutually
30+
* recursive helpers.
31+
*/
32+
33+
// ── Host FFI (Int handles; 0 = null) ─────────────────────────────────────────
34+
35+
pub extern fn dom_query_selector(selector: String) -> Int;
36+
pub extern fn dom_create_element(tag: String) -> Int;
37+
pub extern fn dom_create_text_node(content: String) -> Int;
38+
pub extern fn dom_append_child(parent: Int, child: Int) -> Unit;
39+
pub extern fn dom_replace_child(parent: Int, old_child: Int, new_child: Int) -> Unit;
40+
pub extern fn dom_remove_child(parent: Int, child: Int) -> Unit;
41+
pub extern fn dom_child_at(parent: Int, index: Int) -> Int;
42+
pub extern fn dom_set_attribute(el: Int, name: String, value: String) -> Unit;
43+
pub extern fn dom_remove_attribute(el: Int, name: String) -> Unit;
44+
pub extern fn dom_set_text(node: Int, content: String) -> Unit;
45+
pub extern fn dom_str_eq(a: String, b: String) -> Bool;
46+
47+
// ── Virtual DOM ──────────────────────────────────────────────────────────────
48+
49+
pub enum VNode {
50+
VText(String),
51+
VElem(String, [(String, String)], [VNode])
52+
}
53+
54+
/// Text node.
55+
pub fn text(content: String) -> VNode = VText(content);
56+
57+
/// Fluent element builder (replaces the old non-rendering `h`): arbitrary
58+
/// attributes and children, not the old `{ id: String }`-only stub.
59+
pub fn h(tag: String, attrs: [(String, String)], children: [VNode]) -> VNode =
60+
VElem(tag, attrs, children);
61+
62+
// `len` is not available in the standalone wasm-AOT subset; a `for`-count
63+
// helper is (proven: tests/codegen/test_for_loop). Monomorphic, not
64+
// generic, since wasm codegen generic support is not relied on here.
65+
fn vnode_len(xs: [VNode]) -> Int {
66+
let mut c = 0;
67+
for x in xs {
68+
c = c + 1;
69+
}
70+
c
71+
}
72+
73+
// ── Render: VNode -> real DOM subtree (self-recursive) ───────────────────────
74+
75+
pub fn render(vnode: VNode) -> Int {
76+
match vnode {
77+
VText(content) => dom_create_text_node(content),
78+
VElem(tag, attrs, children) => {
79+
let el = dom_create_element(tag);
80+
for a in attrs {
81+
match a {
82+
(name, value) => dom_set_attribute(el, name, value)
83+
}
84+
}
85+
for child in children {
86+
dom_append_child(el, render(child));
87+
}
88+
el
89+
}
90+
}
91+
}
92+
93+
/// Mount a VNode tree under the first element matching `selector`.
94+
/// Returns `true` on success, `false` if the selector matched nothing.
95+
pub fn mount(selector: String, vnode: VNode) -> Bool {
96+
let parent = dom_query_selector(selector);
97+
if parent == 0 {
98+
false
99+
} else {
100+
dom_append_child(parent, render(vnode));
101+
true
102+
}
103+
}
104+
105+
// ── Reconciler: minimal mutation between two VNode trees ─────────────────────
106+
107+
fn attr_has(attrs: [(String, String)], key: String) -> Bool {
108+
let mut found = false;
109+
for a in attrs {
110+
match a {
111+
(name, value) =>
112+
if dom_str_eq(name, key) { found = true; } else { () }
113+
}
114+
}
115+
found
116+
}
117+
118+
fn patch_attrs(el: Int, olds: [(String, String)], news: [(String, String)]) -> Unit {
119+
for a in news {
120+
match a {
121+
(name, value) => dom_set_attribute(el, name, value)
122+
}
123+
}
124+
for a in olds {
125+
match a {
126+
(name, value) =>
127+
if attr_has(news, name) { () } else { dom_remove_attribute(el, name) }
128+
}
129+
}
130+
()
131+
}
132+
133+
fn replace(parent: Int, old_node: Int, new_v: VNode) -> Int {
134+
let new_node = render(new_v);
135+
dom_replace_child(parent, old_node, new_node);
136+
new_node
137+
}
138+
139+
/// Reconcile `old_v` (currently mounted as handle `old_node` under
140+
/// `parent`) towards `new_v`, performing the minimal DOM mutation.
141+
/// Returns the handle now in place (may differ if the node was replaced).
142+
/// Children are reconciled inline (self-recursion) — see codegen note.
143+
pub fn reconcile(parent: Int, old_node: Int, old_v: VNode, new_v: VNode) -> Int {
144+
match old_v {
145+
VText(old_s) =>
146+
match new_v {
147+
VText(new_s) =>
148+
if dom_str_eq(old_s, new_s) {
149+
old_node
150+
} else {
151+
dom_set_text(old_node, new_s);
152+
old_node
153+
},
154+
VElem(new_tag, new_attrs, new_kids) => replace(parent, old_node, new_v)
155+
},
156+
VElem(old_tag, old_attrs, old_kids) =>
157+
match new_v {
158+
VText(new_s) => replace(parent, old_node, new_v),
159+
VElem(new_tag, new_attrs, new_kids) =>
160+
if dom_str_eq(old_tag, new_tag) {
161+
patch_attrs(old_node, old_attrs, new_attrs);
162+
let no = vnode_len(old_kids);
163+
let nn = vnode_len(new_kids);
164+
let max = if no >= nn { no } else { nn };
165+
let mut i = 0;
166+
while i < max {
167+
if i >= no {
168+
dom_append_child(old_node, render(new_kids[i]));
169+
} else {
170+
if i >= nn {
171+
// Surplus old child: removing the node now at index `nn`
172+
// shifts the next surplus into the same slot.
173+
dom_remove_child(old_node, dom_child_at(old_node, nn));
174+
} else {
175+
reconcile(old_node, dom_child_at(old_node, i),
176+
old_kids[i], new_kids[i]);
177+
}
178+
}
179+
i = i + 1;
180+
}
181+
old_node
182+
} else {
183+
replace(parent, old_node, new_v)
184+
}
185+
}
186+
}
187+
}

affinescript-dom/src/dom.as

Lines changed: 0 additions & 38 deletions
This file was deleted.

docs/ECOSYSTEM.adoc

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,11 @@ The contract is *narrower than older prose claimed* and is exactly this:
139139
|===
140140
|Satellite |Reality |Notes
141141

142-
|`affinescript-dom` |skeleton |`src/dom.as` ~39 lines; `h()`/`mount()` do
143-
not render. INT-08 (#183) builds the reconciler.
142+
|`affinescript-dom` |reconciler (compiles) |INT-08 (#183): `src/dom.as`
143+
(non-parsing 39-line stub) renamed to canonical `src/dom.affine` and
144+
replaced with a real VDOM + render + mount + minimal-mutation
145+
reconciler that compiles end-to-end. Runtime gated on #255 (pre-existing
146+
wasm loop-codegen defect).
144147

145148
|`affinescript-pixijs` |skeleton |Migration-prerequisite scaffold (#56).
146149

@@ -198,8 +201,10 @@ S1..S6; legacy preview1 stdout path is the default until S6
198201
|planned (blocked by INT-03)
199202
|INT-07 |`affinescript-tea` runtime satellite |#182 |open, S2 (blocked by
200203
INT-01)
201-
|INT-08 |DOM reconciler in `affinescript-dom` |#183 |open, S2 (blocked by
202-
INT-02)
204+
|INT-08 |DOM reconciler in `affinescript-dom` |#183 |reconciler
205+
implemented + compiles (resolve→typecheck→codegen→wasm); `.as`→`.affine`
206+
corrected. INT-02 dep cleared. Runtime BLOCKED by #255 (wasm
207+
loop-codegen defect, pre-existing)
203208
|INT-09 |`affinescript-cadre` router/navigation runtime |ledger-only
204209
|planned (blocked by INT-07)
205210
|INT-10 |LSP distribution (`affinescript-lsp`) |ledger-only |planned

docs/TECH-DEBT.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ Component-Model re-target, staged S1..S6); S3+ hard-gated on S2
145145
toolchain (`wasm-tools`/`wasm-component-ld`)
146146
|INT-04 |Publish to JSR/npm |S2 |open #181 (◄ INT-01)
147147
|INT-07 |`affinescript-tea` runtime |S2 |open #182 (◄ INT-01)
148-
|INT-08 |DOM reconciler |S2 |open #183 (◄ INT-02)
148+
|INT-08 |DOM reconciler |S2 |#183 implemented + compiles; `.as`→`.affine`
149+
fixed; runtime blocked by #255 (wasm loop-codegen defect)
149150
|INT-05/06/09/10/11/12 |ledger-only; filed when blocker closes |— |planned
150151
|===
151152

0 commit comments

Comments
 (0)