Version: 2.0.0 Status: Production Ready — Permanent Custom Implementation License: PMPL-1.0-or-later Author: Jonathan D.A. Jewell j.d.a.jewell@open.ac.uk
- Introduction
- Permanence Decision
- Module Inventory
- Core Concepts
- Module Reference
- Architecture Guide
- Testing
- Best Practices
- Examples
The Elm Architecture (TEA) is a pattern for building web applications that provides:
- Predictable state management: All state changes flow through a single update function
- Side-effect isolation: Commands and subscriptions handle async operations
- Type safety: ReScript's type system ensures correctness
- Testability: Pure functions make testing straightforward
PanLL (eNSAID - Environment for NeSy-Agentic Integrated Development) requires:
- Complex state management for multi-panel neurosymbolic reasoning
- Real-time updates from agents
- Predictable behavior for debugging reasoning chains
- Type-safe guarantees for critical operations
TEA provides all of these guarantees.
Status: PERMANENT — this is not a temporary fork or stopgap.
PanLL's custom TEA implementation in src/tea/ (8 modules, ~700 lines) is the
permanent architecture. The earlier plan to migrate to the official
rescript-tea@0.16.0 package has been abandoned. The migration guides
(MIGRATION-TO-RESCRIPT-TEA.md and RESCRIPT-TEA-MIGRATION-GUIDE.md) are
superseded by this document.
- Unmaintained upstream —
rescript-teahas not been updated since 2021. It depends onrescript-webapi@0.7.0, which is incompatible with ReScript 11.1.4+. - No keyboard subscriptions — PanLL needs custom keyboard handling (panel switching, shortcuts). Official rescript-tea has no built-in keyboard support, so custom subscription code is needed regardless.
- Tauri integration — PanLL's command layer wraps Tauri IPC calls. Official rescript-tea's command model doesn't account for desktop bridge APIs.
- ARIA accessibility — Our
Tea_Vdomhas first-class ARIA attribute support (12+ aria-* helpers, role, tabIndex) built directly into the attribute type. Official rescript-tea requires manualpropertycalls. - VDOM diffing — Our
Tea_Renderincludes a complete diff/patch engine (Replace, UpdateProps, UpdateChildren, RemoveNode) with event listener lifecycle management. This would need to be reimplemented on top of rescript-tea anyway. - Message queue —
Tea_Appimplements a dispatch queue that prevents recursive dispatch, which is critical for PanLL's multi-panel architecture where one panel's update can trigger messages in another. - Zero dependencies — The custom TEA has no npm dependencies at all. It compiles with ReScript alone and runs in any browser or Tauri webview.
src/tea/is a first-class PanLL subsystem, not a vendored fork.- New features (WebSocket subscriptions, navigation, batch rendering) are added directly to these modules.
- Bug fixes and optimisations are made in-place.
- The
MIGRATION-TO-RESCRIPT-TEA.mdandRESCRIPT-TEA-MIGRATION-GUIDE.mdfiles in the repo root are historical artefacts and should not be followed.
| Module | Lines | Purpose |
|---|---|---|
Tea.res |
25 | Facade — re-exports all TEA modules |
Tea_App.res |
173 | Core runtime: standardProgram, simpleProgram, dispatch loop, message queue |
Tea_Cmd.res |
61 | Commands: None, Msg, Batch, Call with map/execute |
Tea_Sub.res |
76 | Subscriptions: key-based diffing, enable/cleanup lifecycle |
Tea_Vdom.res |
106 | Virtual DOM: Text/Element nodes, attributes (Property, Style, Event, EventWithValue, EventWithKey), ARIA |
Tea_Html.res |
111 | HTML element constructors (37 elements) + Attrs and Events sub-modules |
Tea_Render.res |
405 | DOM rendering: createElement, diff, applyPatch, event listener tracking, mount/unmount |
Tea_Time.res |
30 | Time subscriptions: every (interval), after (timeout) |
Tea_Animationframe.res |
24 | requestAnimationFrame subscription |
| Total | ~1011 | Complete TEA implementation with VDOM diffing |
Tea.res (facade)
├── Tea_App.res ──────→ Tea_Cmd.res
│ │ Tea_Sub.res
│ └──────────────→ Tea_Render.res ──→ Tea_Vdom.res
├── Tea_Html.res ──────→ Tea_Vdom.res
├── Tea_Time.res ──────→ Tea_Sub.res
└── Tea_Animationframe.res → Tea_Sub.res
No circular dependencies. Tea_Vdom is the leaf module.
User Input → Message → Update (Model + Command) → View → DOM
↑
└──────── Subscriptions ────────────────────┘
- Model: Application state (immutable data structure)
- Update: State transition function
(Model, Msg) → (Model, Cmd) - View: Rendering function
Model → VirtualDOM - Subscriptions: External event sources
Model → Sub
Commands represent side effects to be executed after an update.
// SPDX-License-Identifier: PMPL-1.0-or-later
type t<'msg> // Opaque command type
// Constructors
let none: t<'msg>
let msg: 'msg => t<'msg>
let batch: list<t<'msg>> => t<'msg>
let call: (callbacks<'msg> => unit) => t<'msg>
// Execution
let execute: (t<'msg>, 'msg => unit) => unit
// Mapping
let map: (t<'msg>, 'msg => 'mappedMsg) => t<'mappedMsg>No-op command:
let (newModel, cmd) = (model, Tea_Cmd.none)Immediate message:
let (model, Tea_Cmd.msg(LoadComplete))Async operation:
let fetchUserCmd = Tea_Cmd.call(callbacks => {
Fetch.get("/api/user")
->Promise.then(response => {
callbacks.enqueue(UserLoaded(response))
Promise.resolve()
})
->ignore
})Batch multiple commands:
let (model, Tea_Cmd.batch(list{
Tea_Cmd.msg(LogEvent("Startup")),
Tea_Cmd.call(loadUserData),
Tea_Cmd.call(connectWebSocket)
}))Commands are easily testable:
// SPDX-License-Identifier: PMPL-1.0-or-later
import { assertEquals } from "@std/assert";
import { msg, execute } from '../src/tea/Tea_Cmd.res.js';
Deno.test('executes Msg command', () => {
let dispatched = null;
const dispatch = (m) => { dispatched = m; };
const message = { type: 'TestMsg', value: 42 };
execute(msg(message), dispatch);
assertEquals(dispatched, message);
});Subscriptions represent ongoing event sources (WebSocket, timers, keyboard).
// SPDX-License-Identifier: PMPL-1.0-or-later
type t<'msg> // Opaque subscription type
// Constructors
let none: t<'msg>
let registration: (string, ('msg => unit) => (unit => unit)) => t<'msg>
let batch: list<t<'msg>> => t<'msg>
// Lifecycle
let enable: (t<'msg>, 'msg => unit) => (unit => unit)
let getKeys: t<'msg> => array<string>
// Mapping
let map: (t<'msg>, 'msg => 'mappedMsg) => t<'mappedMsg>No subscriptions:
let subscriptions = _model => Tea_Sub.noneTimer subscription:
let subscriptions = model => {
Tea_Sub.registration("timer", dispatch => {
let intervalId = setInterval(() => {
dispatch(Tick)
}, 1000)
// Cleanup function
() => clearInterval(intervalId)
})
}WebSocket subscription:
let subscriptions = model => {
if model.connected {
Tea_Sub.registration("websocket", dispatch => {
let ws = WebSocket.create("wss://api.example.com")
ws->WebSocket.onMessage(event => {
dispatch(MessageReceived(event.data))
})
// Cleanup
() => ws->WebSocket.close()
})
} else {
Tea_Sub.none
}
}Batch subscriptions:
let subscriptions = model => {
Tea_Sub.batch(list{
keyboardSub(model),
timerSub(model),
websocketSub(model)
})
}Subscriptions must return cleanup functions to prevent memory leaks:
// ✓ CORRECT - cleanup function returned
Tea_Sub.registration("timer", dispatch => {
let id = setInterval(() => dispatch(Tick), 1000)
() => clearInterval(id) // Cleanup
})
// ✗ WRONG - no cleanup, memory leak!
Tea_Sub.registration("timer", dispatch => {
setInterval(() => dispatch(Tick), 1000)
() => () // No cleanup!
})// SPDX-License-Identifier: PMPL-1.0-or-later
import { assertEquals } from "@std/assert";
Deno.test('prevents memory leaks with timers', async () => {
let dispatched = false;
let timerFired = false;
const dispatch = () => { dispatched = true; };
const timerSub = registration('timer', (dispatchFn) => {
const timerId = setTimeout(() => {
timerFired = true;
dispatchFn({ type: 'TimerFired' });
}, 50);
return () => clearTimeout(timerId); // Cleanup
});
const cleanup = enable(timerSub, dispatch);
cleanup(); // Clean up before timer fires
await new Promise(resolve => setTimeout(resolve, 100));
assertEquals(timerFired, false); // Timer was cancelled
assertEquals(dispatched, false);
});Virtual DOM types and constructors for building UIs.
// SPDX-License-Identifier: PMPL-1.0-or-later
type node<'msg>
type property<'msg>
// Node constructors
let text: string => node<'msg>
let node: (string, list<property<'msg>>, list<node<'msg>>) => node<'msg>
// Properties
let class_: string => property<'msg>
let id: string => property<'msg>
let style: (string, string) => property<'msg>
let href: string => property<'msg>
let src: string => property<'msg>
let placeholder: string => property<'msg>
let value: string => property<'msg>
// Events
let onClick: ('msg) => property<'msg>
let onInput: (string => 'msg) => property<'msg>
let onSubmit: ('msg) => property<'msg>
let onMouseEnter: ('msg) => property<'msg>
let onMouseLeave: ('msg) => property<'msg>
// Mapping
let map: (node<'msg>, 'msg => 'mappedMsg) => node<'mappedMsg>Simple text:
Tea_Vdom.text("Hello, World!")Element with attributes:
Tea_Vdom.node("div", list{
Tea_Vdom.class_("container"),
Tea_Vdom.id("app")
}, list{
Tea_Vdom.text("Content")
})Interactive button:
Tea_Vdom.node("button", list{
Tea_Vdom.onClick(Increment),
Tea_Vdom.class_("btn btn-primary")
}, list{
Tea_Vdom.text("Click me")
})Form with input:
Tea_Vdom.node("input", list{
Tea_Vdom.placeholder("Enter your name"),
Tea_Vdom.value(model.name),
Tea_Vdom.onInput(name => UpdateName(name))
}, list{})Main entry point for running TEA applications.
// SPDX-License-Identifier: PMPL-1.0-or-later
type program<'flags, 'model, 'msg>
// Program constructors
let simpleProgram: {
init: 'flags => ('model, Tea_Cmd.t<'msg>),
update: ('model, 'msg) => ('model, Tea_Cmd.t<'msg>),
view: 'model => Tea_Vdom.node<'msg>,
subscriptions: 'model => Tea_Sub.t<'msg>
} => program<'flags, 'model, 'msg>
let standardProgram: {
init: 'flags => ('model, Tea_Cmd.t<'msg>),
update: ('model, 'msg) => ('model, Tea_Cmd.t<'msg>),
view: 'model => Tea_Vdom.node<'msg>,
subscriptions: 'model => Tea_Sub.t<'msg>,
shutdown: 'model => Tea_Cmd.t<'msg>
} => program<'flags, 'model, 'msg>// SPDX-License-Identifier: PMPL-1.0-or-later
// Model.res
type model = {
count: int,
status: [#Idle | #Loading | #Error(string)]
}
let init = (): (model, Tea_Cmd.t<msg>) => (
{count: 0, status: #Idle},
Tea_Cmd.none
)
// Update.res
type msg =
| Increment
| Decrement
| Reset
| LoadData
| DataLoaded(result<string, string>)
let update = (model: model, msg: msg): (model, Tea_Cmd.t<msg>) => {
switch msg {
| Increment => ({...model, count: model.count + 1}, Tea_Cmd.none)
| Decrement => ({...model, count: model.count - 1}, Tea_Cmd.none)
| Reset => ({...model, count: 0}, Tea_Cmd.none)
| LoadData => (
{...model, status: #Loading},
Tea_Cmd.call(callbacks => {
fetchData()->Promise.then(result => {
callbacks.enqueue(DataLoaded(result))
Promise.resolve()
})->ignore
})
)
| DataLoaded(Ok(data)) => ({...model, status: #Idle}, Tea_Cmd.none)
| DataLoaded(Error(err)) => ({...model, status: #Error(err)}, Tea_Cmd.none)
}
}
// View.res
let view = (model: model): Tea_Vdom.node<msg> => {
Tea_Vdom.node("div", list{Tea_Vdom.class_("app")}, list{
Tea_Vdom.node("h1", list{}, list{
Tea_Vdom.text(`Count: ${Int.toString(model.count)}`)
}),
Tea_Vdom.node("button", list{Tea_Vdom.onClick(Increment)}, list{
Tea_Vdom.text("+")
}),
Tea_Vdom.node("button", list{Tea_Vdom.onClick(Decrement)}, list{
Tea_Vdom.text("-")
}),
Tea_Vdom.node("button", list{Tea_Vdom.onClick(Reset)}, list{
Tea_Vdom.text("Reset")
})
})
}
// Subscriptions.res
let subscriptions = (model: model): Tea_Sub.t<msg> => {
Tea_Sub.none
}
// App.res
let app = Tea_App.simpleProgram({
init: init,
update: update,
view: view,
subscriptions: subscriptions
})Current test suite (as of v0.1.0-alpha):
- 97 JavaScript tests via Deno.test (Tea_Cmd, Tea_Sub, Tea_Vdom, Tea_App, Model, Update, View, components)
- 12 Rust tests via cargo test (Tauri backend commands)
- 109 total tests passing
# Run all JS tests (97 tests)
deno task test
# Watch mode
deno task test:watch
# Run Rust backend tests (12 tests)
cd src-tauri && cargo testTests use Deno.test with @std/assert:
// SPDX-License-Identifier: PMPL-1.0-or-later
import { assertEquals } from "@std/assert";
import { update } from '../src/Update.res.js';
Deno.test('Update - increments counter', () => {
const model = { count: 0, status: 'Idle' };
const [newModel, cmd] = update(model, { type: 'Increment' });
assertEquals(newModel.count, 1);
assertEquals(cmd, "None"); // No command
});// ✓ GOOD - Flat, simple structure
type model = {
users: array<user>,
selectedUserId: option<string>,
loading: bool
}
// ✗ BAD - Nested, complex structure
type model = {
data: {
users: {
list: array<user>,
selected: option<{id: string, data: user}>
}
}
}// ✓ GOOD - Explicit variants
type msg =
| UserClicked(string)
| DataLoaded(result<data, error>)
| FormUpdated(formField, string)
// ✗ BAD - Stringly-typed
type msg = {
type_: string,
payload: option<Js.Json.t>
}// ✓ GOOD - Named, reusable command
let loadUserCmd = (userId: string): Tea_Cmd.t<msg> => {
Tea_Cmd.call(callbacks => {
fetchUser(userId)
->Promise.then(user => {
callbacks.enqueue(UserLoaded(Ok(user)))
Promise.resolve()
})
->Promise.catch(err => {
callbacks.enqueue(UserLoaded(Error(err)))
Promise.resolve()
})
->ignore
})
}
// In update:
| LoadUser(id) => (model, loadUserCmd(id))// ✓ GOOD - Proper cleanup
Tea_Sub.registration("websocket", dispatch => {
let ws = connect()
ws.onMessage(msg => dispatch(Received(msg)))
() => { // Cleanup function
ws.close()
ws.onMessage(_ => ()) // Clear handler
}
})Update is pure - easy to test:
Deno.test('handles error state', () => {
const model = { status: 'Loading' };
const msg = { type: 'Error', error: 'Network failed' };
const [newModel, cmd] = update(model, msg);
assertEquals(newModel.status, 'Error');
assertEquals(newModel.error, 'Network failed');
});See Architecture Guide above.
// SPDX-License-Identifier: PMPL-1.0-or-later
type model = {
email: string,
password: string,
errors: list<string>
}
type msg =
| UpdateEmail(string)
| UpdatePassword(string)
| Submit
| SubmitResult(result<unit, string>)
let validateEmail = (email: string): option<string> => {
if String.includes(email, "@") {
None
} else {
Some("Invalid email")
}
}
let update = (model, msg) => {
switch msg {
| UpdateEmail(email) => ({...model, email: email}, Tea_Cmd.none)
| UpdatePassword(pwd) => ({...model, password: pwd}, Tea_Cmd.none)
| Submit => {
let errors = list{}
switch validateEmail(model.email) {
| Some(err) => errors = list{err, ...errors}
| None => ()
}
if List.length(errors) > 0 {
({...model, errors: errors}, Tea_Cmd.none)
} else {
(model, submitFormCmd(model.email, model.password))
}
}
| SubmitResult(Ok()) => (model, Tea_Cmd.msg(NavigateToHome))
| SubmitResult(Error(err)) => ({...model, errors: list{err}}, Tea_Cmd.none)
}
}// SPDX-License-Identifier: PMPL-1.0-or-later
type model = {
messages: array<string>,
connected: bool
}
type msg =
| Connect
| Disconnect
| MessageReceived(string)
| SendMessage(string)
let subscriptions = (model) => {
if model.connected {
Tea_Sub.registration("websocket", dispatch => {
let ws = WebSocket.create("wss://chat.example.com")
ws->onOpen(() => Console.log("Connected"))
ws->onMessage(event => dispatch(MessageReceived(event.data)))
ws->onClose(() => dispatch(Disconnect))
// Cleanup
() => ws->close()
})
} else {
Tea_Sub.none
}
}- Original Elm Architecture: https://guide.elm-lang.org/architecture/
- ReScript Documentation: https://rescript-lang.org/
- PanLL Project: https://github.com/hyperpolymath/panll
This documentation is licensed under PMPL-1.0-or-later.
Copyright (c) 2026 Jonathan D.A. Jewell