TEA Program Model
This document describes the TEA model for MoUI applications (
View,Program,Effect, andSubscription). For an overview, see Architecture.
App-author recipes (timer, clipboard, file open, window resize, shortcuts) live in Non-render component cookbook and the runnable Showcase Platform package. This page is the lifecycle and semantics reference, not a copy-paste cookbook.
MoUI app code builds UI with opaque @moui.View[Msg] values. The standard
shape is a typed TEA loop: view : Model -> View[Msg], events carry typed
messages, update handles those messages, and explicit Effect[Msg] values
model follow-up work. App-level Subscription[Msg] values declare ongoing
event sources that should be started, reused by key, or canceled as the model
changes. Views that need viewport or platform inputs use
view : (Model, ViewEnvironment) -> View[Msg] through
Program::simple_with_environment or Program::new_with_environment. User code
does not call lower, to_spec, ViewSpec, or ViewLoweringSink; those names
are historical guardrail names, not supported app or control APIs.
views/ is a facade over concrete custom view controls and higher-level compositions:
enum CounterMsg { Inc; Dec }
fn view(count : Int) -> @moui.View[CounterMsg] {
@views.column([
@views.text("Count: \{count}"),
@views.row([
@views.button("-", on_click=Dec),
@views.button("+", on_click=Inc),
], spacing=8.0),
], spacing=12.0)
}
Function components are ordinary functions returning View[Msg]. Child
messages are lifted with View::map, for example
todo_row(todo).map(TodoRowMsg). Child updates that return follow-up work lift
their effects with Effect::map, so a parent update can preserve typed child
message composition and structured effect descriptors without exposing runtime
dispatch. Ordinary controls are TEA-first controlled views: app code passes the
current value plus on_input,
on_change, or on_select, then updates the model from the emitted message.
Controls that require complex local state, such as rich text editing or
virtualized resources, keep it in typed runtime slots owned by the control.
They receive controlled values and emit typed messages; they do not receive a
setter or a mutable application-state holder.
Runtime-owned, node-scoped state remains compatible with TEA because it is not
application model state. Hover, focus, pressed state, caret/selection, and
scroll position may live in a transient runtime slot tied to one reconciled
element lifetime. Semantic actions can update that transient state and enqueue
typed messages, but only the application's update function changes the app
model. Rebuilds re-declare the view from the resulting model; unmounting drops
the runtime slot.
Agent actions follow the same loop. Runtime commits the current semantics, validates a generation-tagged typed action, applies transient UI state, delivers typed messages FIFO, drains synchronous TEA work, and commits again. It never manufactures pointer or keyboard events for semantic activation. See Committed Semantics And Agent Actions.
Custom-control entrypoints are app-facing in moui/views. App code,
host tests, smoke checks, and example apps should use helpers such as
@views.text_field, @views.checkbox, @views.picker, and
@moui_richtext.markdown_editor (rich text lives in the moui_richtext
addon, not moui/views). Concrete built-in control implementations should live
in moui/views, implement the message-independent @core.ViewNode trait, and
attach typed children/events/text commands with @core.View::from_node(...).
They should not add @core.View::primitive_*_view constructors or runtime
lowering table entries.
Shared app packages should use Program::simple factories and let platform
entrypoints create AppRuntime values through moui/runtime. Effect-capable
apps should use Program::new when
update returns follow-up work: Effect::send re-enters the typed message loop
directly, and Effect::dispatch gives an effect runner the typed message
dispatcher for app-owned host-service bridges or other callbacks without making
core platform-specific. Effect::run is the structured form for ordinary
one-shot runners that should appear in diagnostics; it adds a stable key, kind,
and label while leaving concrete async execution outside core.
moui/services.ServiceTask::effect is the app-facing helper for files,
clipboard, URLs, settings, appearance, and menus. It turns typed success,
failure, and cancellation into Msg without exposing host request ids, bridge
objects, or completion queues. Effect::task starts a one-shot
cancellable task from an effect update, and Effect::service_task is the
standard helper for service-like one-shot tasks that need the same runtime-owned
cancellation lifecycle plus a stable service descriptor kind: the runtime
records an active task descriptor, completes it on the first typed dispatch,
cancels an older active task when a new task with the same key starts, cancels
active tasks when the runtime is destroyed, and ignores stale task dispatches
after completion or cancellation. Same-key task replacements that change the
descriptor kind are recorded with EffectTaskKindChanged, so tooling can
distinguish a service-like task being swapped for another task category from an
ordinary same-kind restart.
Supersession itself stays silent (last writer wins), but reusing one key for a
different descriptor is an accident worth catching: when the live task's kind
or label differs from the incoming one, the runtime records one entry per
distinct collision shape in the program diagnostics snapshot
(superseded_effect_task_key_count / superseded_effect_task_keys), exposed on
AppRuntime::program_runtime_snapshot and the inspector snapshot. Treat an
effect key as a namespaced identity rather than a display label: prefix it with
the owning module and purpose (for example import.raster.page-3 or
settings.load.theme), never share one key across different kinds or labels,
and keep key, kind, and label identical when intentionally restarting the same
operation so the restart stays a plain last-writer replacement without a
collision record.
@runtime.effect_plan_summary exposes the runtime-owned diagnostic summary of
the effect tree, including batch, send, anonymous dispatch, structured run,
task, none, scheduled leaf count, max depth, structured effect descriptors, and
duplicate descriptor-key counts/names, without running effect callbacks.
Program runtime snapshots also report message queue enqueue, drain, pending,
max-pending, and ignored program-dispatch counters without requiring Msg
values to be serializable. Each program-message drain is bounded as a runtime
turn: messages synchronously queued by clicks, Effect::send /
Effect::dispatch, structured runners, effect tasks, or subscriptions keep
their FIFO order, but work beyond the per-turn bound remains pending for the
next host/runtime entry instead of keeping the current call stack alive
forever. Dispatch closures captured by anonymous
Effect::dispatch or structured Effect::run callbacks are ignored after
AppRuntime::destroy() so late app-owned callbacks cannot re-enter a destroyed
program runtime.
Program constructors also accept
subscriptions=model => ...; each Subscription::listen / Subscription::run
uses a stable key, receives the typed dispatcher, and may return a cleanup
callback. Subscription::timer, Subscription::animation_tick,
Subscription::window_event, Subscription::host_event, and
Subscription::route_event standardize descriptor kinds for common ongoing
source categories without starting any concrete platform work in core.
@runtime.subscription_plan_summary exposes the declared none, batch,
source, duplicate-key counts/names, max-depth, and declared source descriptor
structure without starting sources. Existing keys are reused across model changes only
when their descriptor kind still matches, missing keys are canceled, and a
source that keeps the same key but changes kind is restarted with a
SubscriptionKindChanged lifecycle reason so an app cannot silently keep an old
timer, host-event, or service adapter running under a reused key. Duplicate keys
in one subscription batch are ignored after the first and reported in runtime
diagnostics, and Subscription::map preserves child feature message and
descriptor identity while lifting messages to a parent type. Dispatchers
captured by canceled or destroyed subscriptions are ignored, so stale callbacks
cannot re-enter the model loop after their subscription lifetime ends; program
runtime and inspector snapshots count those ignored subscription dispatches
separately from normal typed dispatch/update counters and expose planned
subscription descriptors, active subscription descriptors, active subscription
kind-count summaries, and lifecycle entries for tooling. Concrete timer,
window, host-event, route, or host-service adapters remain outside core; the
core subscription runtime only owns the platform-neutral lifecycle,
subscription plan diagnostics, and typed dispatch contract. backend
provides the integration-only HostEventSource fanout adapter for
Subscription::host_event, HostWindowEventSource for
Subscription::window_event; @services.TimerSource and
@services.RouteSource are the app-facing adapters for timer ticks and route
events. Browser
history, native URL bars, and OS deep-link dispatch remain platform/app-layer
follow-up work.
Program::with_commands declares typed ProgramCommand[Msg] values alongside
the Program. Runtime keeps the current command map synchronized with the model;
keyboard shortcuts, native application menus, and context-menu selections all
enqueue the declared Msg in FIFO order and pass through the same update.
Apps must not install command closures that mutate model state outside TEA.
Environment-aware TEA apps should use
the *_with_environment constructors instead of taking ComponentContext in their
view layer. In both cases event dispatch flows through typed messages instead of
exposing the runtime tree.