Skip to content

Latest commit

 

History

History
765 lines (596 loc) · 18.4 KB

File metadata and controls

765 lines (596 loc) · 18.4 KB

The Elm Architecture (TEA) Guide for PanLL

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

Table of Contents


Introduction

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

Why TEA for PanLL?

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.


Permanence Decision

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.

Why Not Official rescript-tea?

  1. Unmaintained upstreamrescript-tea has not been updated since 2021. It depends on rescript-webapi@0.7.0, which is incompatible with ReScript 11.1.4+.
  2. 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.
  3. Tauri integration — PanLL's command layer wraps Tauri IPC calls. Official rescript-tea's command model doesn't account for desktop bridge APIs.
  4. ARIA accessibility — Our Tea_Vdom has first-class ARIA attribute support (12+ aria-* helpers, role, tabIndex) built directly into the attribute type. Official rescript-tea requires manual property calls.
  5. VDOM diffing — Our Tea_Render includes 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.
  6. Message queueTea_App implements 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.
  7. Zero dependencies — The custom TEA has no npm dependencies at all. It compiles with ReScript alone and runs in any browser or Tauri webview.

What This Means

  • 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.md and RESCRIPT-TEA-MIGRATION-GUIDE.md files in the repo root are historical artefacts and should not be followed.

Module Inventory

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

Dependency Graph

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.


Core Concepts

The TEA Cycle

User Input → Message → Update (Model + Command) → View → DOM
                ↑
                └──────── Subscriptions ────────────────────┘

Four Core Components

  1. Model: Application state (immutable data structure)
  2. Update: State transition function (Model, Msg) → (Model, Cmd)
  3. View: Rendering function Model → VirtualDOM
  4. Subscriptions: External event sources Model → Sub

Module Reference

Tea_Cmd - Commands

Commands represent side effects to be executed after an update.

API

// 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>

Usage Examples

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)
}))

Testing

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);
});

Tea_Sub - Subscriptions

Subscriptions represent ongoing event sources (WebSocket, timers, keyboard).

API

// 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>

Usage Examples

No subscriptions:

let subscriptions = _model => Tea_Sub.none

Timer 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)
  })
}

Memory Safety

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!
})

Testing

// 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);
});

Tea_Vdom - Virtual DOM

Virtual DOM types and constructors for building UIs.

API

// 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>

Usage Examples

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{})

Tea_App - Application Runtime

Main entry point for running TEA applications.

API

// 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>

Architecture Guide

Complete Application Structure

// 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
})

Testing

Test Coverage

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

Running Tests

# 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 test

Test Structure

Tests 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
});

Best Practices

1. Keep Model Simple

// ✓ 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}>
    }
  }
}

2. Use Variants for Messages

// ✓ 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>
}

3. Extract Complex Commands

// ✓ 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))

4. Always Clean Up Subscriptions

// ✓ 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
  }
})

5. Test Update Function Thoroughly

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');
});

Examples

Example 1: Counter

See Architecture Guide above.

Example 2: Form with Validation

// 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)
  }
}

Example 3: Real-Time Updates

// 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
  }
}

References


License

This documentation is licensed under PMPL-1.0-or-later.

Copyright (c) 2026 Jonathan D.A. Jewell