Skip to content

Commit d11dfc8

Browse files
hyperpolymathclaude
andcommitted
feat(affinescript-tea): The Elm Architecture written in AffineScript
A browser TEA runtime for the Bun-ESM target, in AffineScript: - src/Tea.affine: typed virtual DOM (`Html<M>`, `Attr<M>` with attributes, properties, styles, keys and `Event -> Option<M>` decoders; HTML and SVG), a reconciler with keyed child moves (DOM nodes of surviving keys are reused and moved), `Cmd<M>` (none/batch/send/run_cmd) and `Sub<M>` (window events, animation frames, intervals) diffed by key after every update, and `run(selector, init, update, view, subscriptions)` with in-order message processing and frame-batched renders. - src/tea_host.js: the only JavaScript, a host carve-out implementing the primitive externs (DOM ops, handler slots, rAF/timers, cells, key index). - examples/todo: the reference app. - e2e: Playwright (pinned 1.62.1) drives the app in headless Chromium; 8 tests, including keyed reorder preserving node identity (verified to fail with keyed diffing disabled). Wired into ci.yml's build job. Depends on the compiler fixes in #777 (generic enum/extern kinds, thunks, qualified ctors, free-vars, $AFFINESCRIPT_PATH). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9d4e00b commit d11dfc8

9 files changed

Lines changed: 1209 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ jobs:
116116
run: opam exec -- ./tools/run_codegen_deno_tests.sh
117117
- name: Run native Bun-ESM tests (issue #734)
118118
run: opam exec -- ./tools/run_codegen_bun_tests.sh
119+
- name: affinescript-tea browser tests (headless Chromium)
120+
# The AffineScript TEA runtime (affinescript-tea/src/Tea.affine) driven
121+
# through its reference app in a real browser: mount, controlled
122+
# input, async commands, keyed reorder preserving DOM-node identity,
123+
# subscriptions. Playwright is pinned in e2e/bun.lock.
124+
working-directory: affinescript-tea/e2e
125+
run: |
126+
bun install --frozen-lockfile
127+
bunx --no-install --bun playwright install --with-deps chromium
128+
opam exec -- bun test --timeout 60000
119129
- name: Run face-transformer regression tests
120130
run: opam exec -- ./tools/run_face_transformer_tests.sh
121131
- name: Issue #35 Phase 3 — block extension.ts regression

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,6 @@ tools/vendor/
117117
/.claude/worktrees/
118118

119119
.editorconfig
120+
121+
# affinescript-tea browser test deps
122+
/affinescript-tea/e2e/node_modules/

‎affinescript-tea/README.adoc‎

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@ AffineScript modules. INT-07 (issue #182).
99

1010
toc::[]
1111

12-
== What this is
12+
This package holds two TEA runtimes:
13+
14+
* *`src/Tea.affine`* — The Elm Architecture *written in AffineScript*, for
15+
browser applications compiled with the Bun-ESM backend: typed virtual
16+
DOM, keyed reconciler, commands, subscriptions. See
17+
<<affinescript-tea-runtime>>.
18+
* *`mod.js`* — the host-side run loop for WASM modules that expose the
19+
`lib/tea_bridge.ml` ABI (below).
20+
21+
== What this is (WASM bridge runtime)
1322

1423
The compiler-internal `lib/tea_bridge.ml` defines a TEA runtime ABI a
1524
conforming WASM module exposes: `affinescript_init()`,
@@ -64,3 +73,114 @@ INT-07 first general runtime: `TeaApp` (`load`/`init`/`dispatch`/
6473
canonical bridge (`affinescript tea-bridge`) and a hand-built
6574
re-entrancy fixture — see `mod_test.js` (9 tests). The router/navigation
6675
runtime is a separate satellite (INT-09, `lib/tea_router.ml`).
76+
77+
[[affinescript-tea-runtime]]
78+
== The AffineScript runtime (`src/Tea.affine`)
79+
80+
A browser application is an initial state and three pure functions:
81+
82+
[source,affinescript]
83+
----
84+
use Tea::{Html, Cmd, Sub, run, div, button, text, on_click, none, no_sub};
85+
86+
pub enum Msg { Inc, Dec }
87+
88+
pub fn update(msg: Msg, n: Int) -> (Int, Cmd<Msg>) = match msg {
89+
Msg::Inc => (n + 1, none()),
90+
Msg::Dec => (n - 1, none())
91+
};
92+
93+
pub fn view(n: Int) -> Html<Msg> =
94+
div([], [button([on_click(Msg::Dec)], [text("-")]),
95+
text(int_to_string(n)),
96+
button([on_click(Msg::Inc)], [text("+")])]);
97+
98+
pub fn main() -> Unit =
99+
run("#root", (0, none()), update, view, fn(n) => no_sub());
100+
----
101+
102+
What the runtime provides:
103+
104+
* *`Html<M>` / `Attr<M>`* — elements (HTML and SVG), attributes, DOM
105+
properties, inline styles, keys, and event decoders
106+
(`On(event, Event -> Option<M>)`; returning `None` ignores the event, as
107+
`on_enter` does for non-Enter keys). `map_html` re-tags a component's
108+
messages.
109+
* *Keyed reconciliation* — when every child carries a `key`, surviving
110+
children keep their DOM node and are *moved*, so focus, scroll and
111+
in-progress input survive reordering. Otherwise children patch by position.
112+
Event handlers are re-pointed in place: the DOM listener is installed once
113+
per node and event.
114+
* *`Cmd<M>`* — `none`, `batch`, `send(msg)` and `run_cmd(fn(dispatch) => …)`,
115+
which expresses any effect (timers, IndexedDB, WebAssembly calls); `map_cmd`.
116+
* *`Sub<M>`* — `on_window(event, decode)`, `on_frame(f)`, `every(ms, f)`,
117+
`subs([...])`. They are diffed by key after every update, so a
118+
subscription returned only while some state holds is added and removed
119+
with that state.
120+
* *Ordering* — messages are processed one at a time, in order. A message
121+
dispatched during an update is queued. Renders are batched to the next
122+
animation frame.
123+
* *Linear list helpers* — `tea_map`, `tea_filter`, `tea_concat`. The
124+
prelude's `map`/`filter` append with `++` and are quadratic on the JS
125+
target.
126+
127+
=== Building and running an app
128+
129+
[source,sh]
130+
----
131+
export AFFINESCRIPT_PATH=/path/to/affinescript/affinescript-tea/src
132+
affinescript compile --bun-esm App.affine -o app.bun.js
133+
----
134+
135+
Then serve a page that loads the host before the app:
136+
137+
[source,html]
138+
----
139+
<div id="root"></div>
140+
<script type="module">
141+
import "./tea_host.js";
142+
import { main } from "./app.bun.js";
143+
main();
144+
</script>
145+
----
146+
147+
`tea_host.js` is the runtime's only JavaScript: a host carve-out that
148+
implements the primitive `extern fn`s (create node, set attribute,
149+
`requestAnimationFrame`, timers, cells) as globals. Every decision is made in
150+
`Tea.affine`: what to render, diffing, keyed moves, message order, and
151+
subscription sets.
152+
153+
=== Tests
154+
155+
`examples/todo/Todo.affine` is the reference app. `e2e/todo.test.js` drives
156+
it in headless Chromium and covers:
157+
158+
* mount and the init command;
159+
* a controlled input with Enter;
160+
* an asynchronous command;
161+
* keyed reorder preserving DOM-node identity;
162+
* toggle and remove;
163+
* a subscription that starts and stops with the model;
164+
* no runtime errors.
165+
166+
The keyed-reorder test is known to fail when keyed diffing is disabled.
167+
168+
[source,sh]
169+
----
170+
dune build # at the repo root: the compiler
171+
cd affinescript-tea/e2e
172+
bun install --frozen-lockfile
173+
bun test
174+
----
175+
176+
Playwright is pinned to 1.62.1 (Chromium revision 1234).
177+
178+
=== Limits (current compiler)
179+
180+
* `run` takes the initial `(Model, Cmd<Msg>)` *value*, not an `init`
181+
function. A zero-parameter function declaration is typed as its bare
182+
result, so it cannot be passed where a `() -> T` thunk is expected.
183+
* Generic *structs* are not supported by the type checker, so the program
184+
is passed as separate functions rather than one record.
185+
* A qualified pattern on an imported enum (`Html::Element(..)`) needs the
186+
constructor imported by name, not just the type.

‎affinescript-tea/e2e/bun.lock‎

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎affinescript-tea/e2e/package.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "affinescript-tea-e2e",
3+
"private": true,
4+
"type": "module",
5+
"description": "Browser tests for the AffineScript TEA runtime (Tea.affine + tea_host.js).",
6+
"scripts": {
7+
"test": "bun test --timeout 60000"
8+
},
9+
"devDependencies": {
10+
"playwright": "1.62.1"
11+
}
12+
}

‎affinescript-tea/e2e/todo.test.js‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// Browser tests for the AffineScript TEA runtime, driven through the Todo
5+
// reference app (../examples/todo/Todo.affine) in headless Chromium.
6+
//
7+
// Run: cd affinescript-tea/e2e && bun install --frozen-lockfile && bun test
8+
// Needs the compiler built (`dune build` at the repo root) and Playwright's
9+
// Chromium (`bunx playwright install chromium` if not cached).
10+
11+
import { afterAll, beforeAll, expect, test } from "bun:test";
12+
import { mkdtempSync, copyFileSync, writeFileSync } from "node:fs";
13+
import { tmpdir } from "node:os";
14+
import { join } from "node:path";
15+
import { chromium } from "playwright";
16+
17+
const repo = new URL("../../", import.meta.url).pathname;
18+
const teaSrc = join(repo, "affinescript-tea/src");
19+
const site = mkdtempSync(join(tmpdir(), "tea-e2e-"));
20+
21+
let server;
22+
let browser;
23+
let page;
24+
const consoleErrors = [];
25+
26+
/** Compile the Todo example to Bun-ESM into the served directory. */
27+
function compileTodo() {
28+
const out = Bun.spawnSync({
29+
cmd: [join(repo, "_build/default/bin/main.exe"), "compile", "--bun-esm",
30+
join(repo, "affinescript-tea/examples/todo/Todo.affine"), "-o", join(site, "todo.bun.js")],
31+
cwd: join(repo, "affinescript-tea/examples/todo"),
32+
env: { ...process.env, AFFINESCRIPT_STDLIB: join(repo, "stdlib"), AFFINESCRIPT_PATH: teaSrc },
33+
});
34+
if (out.exitCode !== 0) {
35+
throw new Error(`compile failed:\n${out.stdout}\n${out.stderr}`);
36+
}
37+
}
38+
39+
beforeAll(async () => {
40+
compileTodo();
41+
copyFileSync(join(teaSrc, "tea_host.js"), join(site, "tea_host.js"));
42+
writeFileSync(
43+
join(site, "index.html"),
44+
`<!doctype html><meta charset="utf-8"><title>todo</title><div id="root"></div>
45+
<script type="module">import "./tea_host.js"; import { main } from "./todo.bun.js"; main();</script>`,
46+
);
47+
server = Bun.serve({
48+
port: 0,
49+
fetch(req) {
50+
const path = new URL(req.url).pathname;
51+
const file = Bun.file(join(site, path === "/" ? "index.html" : path.slice(1)));
52+
return new Response(file);
53+
},
54+
});
55+
browser = await chromium.launch();
56+
page = await browser.newPage();
57+
page.on("console", (m) => {
58+
if (m.type() === "error") consoleErrors.push(m.text());
59+
});
60+
page.on("pageerror", (e) => consoleErrors.push(String(e)));
61+
await page.goto(`http://localhost:${server.port}/`);
62+
await page.waitForSelector("#app");
63+
});
64+
65+
afterAll(async () => {
66+
await browser?.close();
67+
server?.stop(true);
68+
});
69+
70+
/**
71+
* Poll `read` until `check(value)` passes (or 5 s elapse), then assert it.
72+
* Rendering is batched to the next animation frame, so DOM reads after an
73+
* interaction must wait for it.
74+
*/
75+
async function eventually(read, check) {
76+
const deadline = Date.now() + 5000;
77+
let value = await read();
78+
while (Date.now() < deadline) {
79+
try {
80+
check(value);
81+
return;
82+
} catch {
83+
await new Promise((r) => setTimeout(r, 20));
84+
value = await read();
85+
}
86+
}
87+
check(value);
88+
}
89+
90+
/** Text of every item title, in DOM order. */
91+
const titles = () => page.$$eval("#items .title", (els) => els.map((e) => e.textContent));
92+
93+
/** Add an item by typing it and pressing Enter. */
94+
async function add(title) {
95+
await page.fill("#draft", title);
96+
await page.press("#draft", "Enter");
97+
await eventually(titles, (v) => expect(v).toContain(title));
98+
}
99+
100+
test("mounts the initial view and runs the init command", async () => {
101+
expect(await page.textContent("h1")).toBe("Todo");
102+
expect(await page.textContent("#count")).toBe("0 left");
103+
await eventually(() => page.textContent("#notice"), (v) => expect(v).toBe("ready"));
104+
});
105+
106+
test("controlled input + Enter adds an item and clears the draft", async () => {
107+
await add("milk");
108+
expect(await page.inputValue("#draft")).toBe("");
109+
expect(await page.textContent("#count")).toBe("1 left");
110+
await eventually(() => page.textContent("#notice"), (v) => expect(v).toBe("added milk"));
111+
});
112+
113+
test("the add button and an empty draft", async () => {
114+
await page.click("#add");
115+
expect(await titles()).toEqual(["milk"]);
116+
await page.fill("#draft", "eggs");
117+
await page.click("#add");
118+
await eventually(titles, (v) => expect(v).toEqual(["milk", "eggs"]));
119+
});
120+
121+
test("keyed reorder moves the existing DOM node instead of recreating it", async () => {
122+
await add("bread");
123+
await page.$eval('#items li[data-id="3"]', (li) => {
124+
li.__marker = "kept";
125+
});
126+
await page.click('#items li[data-id="3"] .top');
127+
await eventually(titles, (v) => expect(v).toEqual(["bread", "milk", "eggs"]));
128+
const marker = await page.$eval("#items li:first-child", (li) => li.__marker ?? null);
129+
expect(marker).toBe("kept");
130+
});
131+
132+
test("toggling updates the checkbox, class and count", async () => {
133+
await page.click('#items li[data-id="1"] input[type=checkbox]');
134+
await eventually(() => page.textContent("#count"), (v) => expect(v).toBe("2 left"));
135+
expect(await page.getAttribute('#items li[data-id="1"]', "class")).toBe("item done");
136+
expect(await page.isChecked('#items li[data-id="1"] input[type=checkbox]')).toBe(true);
137+
});
138+
139+
test("removing an item removes exactly its node", async () => {
140+
await page.click('#items li[data-id="2"] .remove');
141+
await eventually(titles, (v) => expect(v).toEqual(["bread", "milk"]));
142+
expect(await page.$$eval("#items li", (els) => els.length)).toBe(2);
143+
});
144+
145+
test("a subscription starts and stops with the model", async () => {
146+
await page.click("#tick");
147+
await eventually(async () => Number(await page.textContent("#ticks")), (v) => expect(v).toBeGreaterThan(3));
148+
await page.click("#tick");
149+
await page.waitForTimeout(60);
150+
const stopped = Number(await page.textContent("#ticks"));
151+
await page.waitForTimeout(150);
152+
expect(Number(await page.textContent("#ticks"))).toBe(stopped);
153+
});
154+
155+
test("no runtime errors were reported", () => {
156+
expect(consoleErrors).toEqual([]);
157+
});

0 commit comments

Comments
 (0)