tea_core

The vocabulary-free half of the Elm architecture: what a tree owes a reconciler, the reconciler, the walk, trait App, and subscriptions.

Nothing here names a widget, a tag or a terminal. A vocabulary (packages/tea-term, packages/tea-dom) implements Tree for its node type and gets the rest:

use tea_core/tree.{Tree, Rel, Unrelated, Same, SelfDiffers}
use tea_core/diff.{diff, apply}

impl[M] Tree[Widget[M]] {
  fn kids(w: Widget[M]) -> List[Widget[M]] = ...
  fn rekid(w: Widget[M], ks: List[Widget[M]]) -> Widget[M] = ...
  fn relate(old: Widget[M], new: Widget[M]) -> Rel = ...
}

The impl lives in the module that declares the vocabulary (the orphan rule is per module), and one impl covers every message type.

The Tree contract

Diffing

diff(old, new) is a list of patches and apply(old, patches) replays it: apply(old, diff(old, new)) == new, and equal trees diff to []. A patch is an address (path) and an op: Replace, SetSelf (a whole node whose own data replaces the target's, children kept), AppendKids, TruncateKids, and for keyed lists InsertKid, RemoveKid, MoveKid. An unchanged sibling is never mentioned, and patches apply in emission order.

Children pair by key when every child has one and no two share it, so a deletion in the middle is one op and a moved child is moved, not rebuilt. Otherwise they pair by index, and a middle deletion rewrites the tail.

Walking

fold_preorder(w, init, f) visits parents before children, left to right, passing f the accumulator, the node and its address. Event routing in a vocabulary is written on it.

trait App

trait App[M] has type Msg, type View, effect E, update and view. update answers (M, Cmd[M.Msg]) and its row is !M.E, which defaults to !(): an impl that says nothing has a pure update, and one that needs io writes effect E = !io. A generic driver can do nothing with an A.View, so drivers take the view function as a parameter.

Commands

A Cmd is data: NoCmd, SendMsg(msg) (one more message in this same turn), BatchCmd(cmds) and Fetch(url, tag). cmd.fold_msg(m, msg, update) runs a turn: the message, then every commanded message first in, first out. More than CMD_FOLD_LIMIT commanded messages or fetches in one turn panics rather than truncating.

Fetch only describes a request: the host does the getting and answers in a later turn, with the same tag, as Ok(body) or Err(reason) (a network failure or a non-2xx status is an Err, not a crash). A driver that can fetch calls cmd.fold_cmds, which answers the model and the requested FetchReqs; fold_msg has no host to ask and panics on a turn that commanded one.

Subscriptions

Sub[M] is data too: Tick(every_ms, msg) names the message a timer means. The model declares its subscriptions (subs(m)), the driver owns all timing and re-reads the declaration after every update, and a due timer's message is taken from the declaration current when it fires. elapse is handed the milliseconds the driver measured; a timer that missed whole periods fires once and keeps its phase.

Changes between versions: CHANGELOG.md. Design background: `docs/dom-bridge-design.md` (in Chinese).

API

tea_core/app

App

trait App[M] {
  type 
  type 
  fn update[M: App](m: M, msg: M.Msg) -> (M, Cmd[M.Msg]) !M.E
  fn view[M: App](m: M) -> M.View
}

An application: update folds one message into the model and says what else the turn asks for, view describes the whole screen for a model. Both pure, so both are testable with ==.

tea_core/cmd

impls registered by this module (visible program-wide, no use needed): Show[Cmd[M]], Show[FetchReq]

Cmd

type Cmd[M] = NoCmd | SendMsg(msg: M) | BatchCmd(cmds: List[Cmd[M]]) | Fetch(url: String, tag: String)

What a turn asks for besides a new model.

NoCmd is nothing, SendMsg is one more message for this turn, and BatchCmd is several commands in order. These are Elm's Cmd.none, Cmd.msg and Cmd.batch; the names differ because None is already Option's and a constructor that shadows it in every importing module is not worth the resemblance.

Fetch(url, tag) asks the host to GET url and to answer with a supply carrying tag, which the application's own function turns into a message for a later turn. The guest only describes the request; it is not run here and nothing about its answer is known in the turn that asked. tag is the application's name for the request (the same url may be asked for twice for different reasons) and is returned verbatim.

FetchReq

type FetchReq = { url: String, tag: String }

One fetch a turn asked for, as the host receives it.

cmd.CMD_FOLD_LIMIT

const CMD_FOLD_LIMIT: Int

How many commanded messages one turn may fold before it is judged a loop.

The bound is on the total messages a turn's commands add, not on how deeply they nest: a batch that fans out is the same runaway as a message that re-issues itself, and counting rounds would leave the first one unbounded. Sixty-four is far past any hand-written chain (the one client in tree issues a chain of length one) and far short of a wait a user would sit through.

cmd.msgs

fn msgs[M](c: Cmd[M]) -> List[M]

The messages a command asks for, left to right, batches flattened.

cmd.fetches

fn fetches[M](c: Cmd[M]) -> List[FetchReq]

The fetches a command asks for, left to right, batches flattened.

cmd.fold_msg

fn fold_msg[Mo, M, !e](m: Mo, msg: M, step: fn(Mo, M) -> (Mo, Cmd[M]) !e) -> Mo !e

Fold one message into the model, then everything its commands asked for, and answer the model the turn ends with.

step is the application's update; a driver passes its own so that the fold is written once for every vocabulary rather than once per driver. A driver with no host to fetch for uses this one, and a turn that commanded a Fetch panics here (see the header).

cmd.fold_msgs

fn fold_msgs[Mo, M, !e](m: Mo, queue: List[M], step: fn(Mo, M) -> (Mo, Cmd[M]) !e) -> Mo !e

fold_msg for messages that arrive together, such as the timers a poll found due. The list is not itself commanded, so it does not count against CMD_FOLD_LIMIT; what the turn commands does.

cmd.fold_cmds

fn fold_cmds[Mo, M, !e](m: Mo, msg: M, step: fn(Mo, M) -> (Mo, Cmd[M]) !e) -> (Mo, List[FetchReq]) !e

fold_msg for a driver that can fetch: the model the turn ends with, and every fetch its commands asked for, in the order they were asked.

tea_core/diff

impls registered by this module (visible program-wide, no use needed): Show[Op[W]]

Op

type Op[W] = Replace(w: W) | SetSelf(w: W) | AppendKids(ws: List[W]) | TruncateKids(keep: Int) | InsertKid(at: Int, w: W) | RemoveKid(at: Int) | MoveKid(from: Int, to: Int)

One edit operation. Replace swaps a whole subtree, SetSelf takes a node's own data and keeps the children already in place, the two tail ops grow or shrink a node's child list, and the three keyed ops edit it in the middle.

SetSelf carries a whole node where an attribute-carrying op would carry attributes, because core has no way to name a vocabulary's attribute type (tree.dawn, wall one). The payload is not a copy -- it is the node diff was handed -- and it cannot be normalised to "the node without its children" either, since a one-child wrapper has no spelling for "no child".

MoveKid is the one op a host must not implement as a removal and a rebuild. Its whole value is that the node the host already has is the node that ends up at to.

Patch

type Patch[W] = { path: List[Int], op: Op[W] }

One edit at one address. path is the chain of child indices from the root, where a child index is an index into kids.

diff.diff

fn diff[W: Tree + Eq](old: W, new: W) -> List[Patch[W]]

Every difference between old and new, addressed from the root. Equal trees produce the empty list.

diff.apply

fn apply[W: Tree](w: W, ps: List[Patch[W]]) -> W

Replay patches in order. An address that does not exist in w leaves the node unchanged: apply stays total, and the roundtrip tests are what keep diff from ever emitting such an address.

tea_core/sub

impls registered by this module (visible program-wide, no use needed): Show[Sub[M]]

Sub

type Sub[M] = Tick(every_ms: Int, msg: M)

What a model can ask the driver for: a message every every_ms milliseconds, as the driver counts them (tea_term counts monotonic time). Intervals below 1ms behave as 1ms (see intervals).

Pending

type Pending = { every_ms: Int, waited_ms: Int }

One armed timer: its interval and how much waiting it has accumulated.

sub.intervals

fn intervals[M](subs: List[Sub[M]]) -> List[Int]

The interval of each subscription, clamped to at least 1ms so a zero or negative interval cannot spin the poll loop.

sub.arm

fn arm(iv: List[Int]) -> List[Pending]

Fresh timers for a declaration, nothing waited yet.

sub.poll_ms

fn poll_ms(ps: List[Pending]) -> Option[Int]

How long the driver may block before the nearest timer comes due, or None when there are no timers and it may block on input forever.

sub.elapse

fn elapse(ps: List[Pending], ms: Int) -> (List[Pending], List[Int])

Account ms of elapsed time to every timer. Returns the advanced timers and the indices (declaration order) of those that came due. A timer fires once per call and keeps what it waited past its last boundary, so a late poll drifts rather than bursts: periods missed whole are skipped, not owed.

sub.due_msgs

fn due_msgs[M](subs: List[Sub[M]], due: List[Int]) -> List[M]

The messages the due indices mean, read from the declaration current at fire time. An index the declaration no longer covers is dropped.

tea_core/tree

impls registered by this module (visible program-wide, no use needed): Show[Rel]

Rel

type Rel = Unrelated | Same | SelfDiffers

How a vocabulary says two nodes at the same address are related. Closed, and about semantics rather than hosts: the reconciler asks only "may I diff these in place, and did the node's own data change".

Tree

trait Tree[W] {
  fn kids[W: Tree](w: W) -> List[W]
  fn rekid[W: Tree](w: W, ks: List[W]) -> W
  fn relate[W: Tree](old: W, new: W) -> Rel
  fn key[W: Tree](w: W) -> Option[String]
}

The contract. Four members: walk children, decide a pair's fate, rebuild a node around new children, and name a node among its siblings. Only the first three are obligations -- key has a default, so a vocabulary that has no notion of identity says nothing and gets the pairing it had before the member existed.

fn kids[W: Tree](w: W) -> List[W]

This node's children in order; a leaf has none. diff pairs these by index and apply addresses through them, so an index in a Patch.path is an index into exactly this list.

fn rekid[W: Tree](w: W, ks: List[W]) -> W

w with its children replaced. Must be total: a leaf handed children is expected to ignore them and return itself, which is what keeps apply total against an address that does not exist.

fn relate[W: Tree](old: W, new: W) -> Rel

Whether the pair may be diffed in place, and whether the node's own data (everything that is not a child) changed. Only ever called on a pair the reconciler has already found unequal, so a vocabulary never has to re-compare fields the == shortcut already settled.

fn key[W: Tree](w: W) -> Option[String]

This node's identity among its siblings, or None when it has none. diff pairs a child list by key when every child answers one and no two answer the same, and by index otherwise, so this member is what turns a deletion in the middle of a list into a deletion rather than a rewrite of everything after it.

String and not a vocabulary-chosen type: wall one above rules out an unbounded projection, so the identity has to be built from a type core already knows. The default body is what keeps the other three members the whole of what an unkeyed vocabulary owes.

tea_core/walk

walk.fold_preorder

fn fold_preorder[W: Tree, A](w: W, init: A, f: fn(A, W, List[Int]) -> A) -> A

Pre-order fold with the path in hand: f sees the accumulator, the node, and the node's address from the root. The root's address is the empty path.