Controllers
A controller is a UIControllerBase subclass — an observable object graph that a view's components bind to,
and the surface a command runs against. Registering a route with application.Route<TView, TController>
pairs the two; a route with no controller (application.Route<TView>) has nothing to bind and no commands.
Observable state
UIControllerBase derives from RecursiveObservable. A [RecursiveMember] partial property generates the
notifying setter and the path segment a binding resolves against:
internal sealed partial class CounterController : UIControllerBase
{
[RecursiveMember]
public partial int Count { get; set; }
}
A property can hold a nested observable object instead of a scalar — also [RecursiveMember], also
partial, so a binding path can walk into it ("TableGroup.Striped"):
internal sealed partial class TableGroupContext : DemoGroupContext
{
[RecursiveMember]
public partial bool Striped { get; set; }
}
internal sealed partial class TableMainController() : DemoStandardController
{
[RecursiveMember]
public partial TableGroupContext TableGroup { get; set; } = new();
}
A collection of items is a RecursiveCollection<T> (T : RecursiveObservable), which forwards every item's
own changes as collection path changes. Its property is [RecursiveMember(false)] — no setter is generated,
because the collection is mutated in place (Add, Remove, indexer) rather than replaced:
[RecursiveMember(false)]
public RecursiveCollection<DemoDeploymentRow> Items { get; } = [.. DemoDeploymentRow.CreateDeployments()];
[RecursiveMember(false)] still registers the path segment but generates no setter — the same attribute an
item's own Id needs:
internal sealed partial class DemoDeploymentRow : RecursiveObservable, IBindableItem
{
[RecursiveMember(false)]
public string Id { get; init; } = string.Empty;
[RecursiveMember]
public partial string Service { get; set; } = string.Empty;
}
A notifying Id would let an item be re-keyed after insertion, leaving the collection's internal lookup
pointing at the old key — so Id is always [RecursiveMember(false)], never a plain [RecursiveMember].
Commands
[UICommand] marks a method the client can invoke:
[UICommand]
public void Increment() => Count++;
public UICommandAttribute() { }
public UICommandAttribute(string name)
public string? Name { get; }
public UICommandConcurrencyMode ConcurrencyMode { get; init; } = UICommandConcurrencyMode.Exclusive;
The optional constructor argument (or Name) is the external command name a view's OnClick/On refers
to; unset, the client name is the method name. ConcurrencyMode is Exclusive (default — only one
invocation runs at a time) or Background (concurrent invocations are allowed). A command may be static
— discovery covers static methods deliberately, since a method touching no instance state is exactly what
the analyzer asks to be made static.
A command's parameters come from the arguments a view's On/OnClick call names, matched by parameter
name; a value the client did not send falls back to the parameter's default, then to null/default for
a nullable or reference type, or throws if the parameter is required. CancellationToken, if present, must
be the last parameter and is supplied by the runtime, not by the client. A command may return:
| Return type | Effect |
|---|---|
void |
Always succeeds, no effects. |
UICommandResult |
Whatever it says. |
Task / ValueTask |
Awaited, then always succeeds, no effects. |
Task<UICommandResult> / ValueTask<UICommandResult> |
Awaited, then whatever it says. |
UICommandResult.Ok(effects) and UICommandResult.Fail(error, effects) are the two factories — a failed
result must carry an error message and a successful one must not. Effects is a list of ClientEffects the
client applies after the command's own changes; see interactions and effects
for what a ClientEffect can do.
Arguments from the view
OnClick(command, params KeyValuePair<string, UIActionArgument>[] arguments) and the plain On overload
take named arguments built with UIAction's static helpers:
new ButtonComponent()
.OnClick(nameof(TableExamplesController.RestartRow), UIAction.ArgCurrentItemKey("id"))
[UICommand]
public void RestartRow(string id) => ActionGroup.Restart(id);
| Helper | Resolves to |
|---|---|
UIAction.Arg(name, value) |
A literal value. |
UIAction.ArgCurrentItem(name) |
The current item — inside an item template, the row itself. |
UIAction.ArgCurrentItemKey(name) |
The current item's key. |
UIAction.ArgRoot(name, path) / ArgParent(name, path) / ArgRelative(name, path) |
A binding path, in the named scope. |
There is no "current item index" argument — an item is addressed by key, so the click site never carries a
position; ArgCurrentItem/ArgCurrentItemKey are what a row-level command reaches for.
Context
Every controller has a Context (UIContext), available once the runtime attaches it (before that,
reading it throws):
| Member | What it gives |
|---|---|
Dialogs, Downloads, Uploads |
The dialog, download and upload services for the current connection. |
Translate(key) |
Translates a key using the current session's language. |
Services |
The application's IServiceProvider. |
Logger |
The controller's logger. |
Route |
The UIRouteDefinition this controller runs on — fixed for the runtime's lifetime. |
Handle |
The connection a command is running for — the invoking connection during a command, the attached one otherwise. Differs from the attached connection only under UIRuntimeLifetime.PerClient. |
GetSessionAsync(cancellationToken) |
Reads the stored session behind this connection, or null if signed out or expired. |
SignInAsync(userId, roles, permissions, cancellationToken) |
Marks the session authenticated; takes effect on the next navigation (sign-in rotates the session id, and only a shell render can write that). |
SignOutAsync(cancellationToken) |
Removes the session — every later command on this connection is refused. |
UpdateSessionAsync(update, cancellationToken) |
Applies a change to the stored session — language, theme, or anything else that must outlive this connection. |
Initialization and errors
OnInitializeAsync(CancellationToken) runs once, after the runtime context is attached and before the
controller is considered ready — the place to load an items window or anything else that needs Context:
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
{
await Rows.LoadWindowAsync(new UIItemWindowRequest(UIItemAnchor.Start, 50), cancellationToken).ConfigureAwait(false);
}
HandleRuntimeExceptionAsync(RuntimeExceptionContext, CancellationToken) is called when something outside a
command throws while the runtime is driving this controller; the base implementation logs and returns
RuntimeExceptionResult.Empty — override it to react instead. A failed command is different: it is
reported to the client automatically (see interactions and effects) and
never reaches this method.
Runtime behaviour
[UIControllerRuntime] on the controller class configures how its changes reach the client:
[UIControllerRuntime(UpdateMode = UIControllerUpdateMode.Direct)]
internal sealed partial class LiveController : UIControllerBase
UpdateMode is Batch (default — changes accumulate and flush on FlushIntervalMilliseconds, 50ms by
default) or Direct (changes are pushed as they occur). In Batch mode a command's own result does not
necessarily carry every change it made — the scheduled flush may already have shipped some of them over the
push channel; the client applies both channels, so this is invisible to a reader but matters to a test that
inspects the command result directly. A route registered with ControllerUpdates(mode) overrides the
attribute for that route specifically — see routing.
Runtime lifetime — how long a runtime instance is kept once a window leaves — is an application-wide setting,
UIPersistenceOptions.Lifetime (UIRuntimeLifetime), configured through
UIApplicationBuilder.ConfigurePersistence(Action<UIPersistenceOptions>):
| Value | Kept until |
|---|---|
PerPage (page-scoped) |
The window navigates away from the address — coming back is a fresh page. |
PerWindow (default) |
The window — a browser tab, a desktop window — closes or times out; coming back finds the page as it was left. |
PerClient |
Every window of the client shares one runtime; changes fan out to all of them. |
DisconnectedRetention (default 10 minutes) is how long a runtime with no attached connection is kept
before it is cleaned up. State survives a reload if and only if it is in the controller — there is no
separate page snapshot, so a property the view never binds is not implicitly durable, and a property the
view does bind is durable regardless of what it is.
UnclaimedRenderRetention (default 30 seconds) is the same for a runtime the page render built and no client
ever presented. A page render reads its values off a runtime of its own and hands it to the attach that
follows, normally within a moment; one that no client claimed — a crawler, a health check, a tab closed before
it connected — is dead the moment that window passes, and keeping it for the full DisconnectedRetention costs
a controller's worth of memory per unclaimed render. Losing the race costs the attach nothing but a fresh
runtime.
Reaching another runtime
A command runs on its own runtime and its result reaches its own client. What another user's open page sees is
the change to their controller, and the way to make one is to run on their runtime: IUIRuntimeAccess.InvokeAsync
(Context.Runtime) takes a delegate, runs it under that runtime's lock and flushes what it changed. It carries
value changes only — a NavigateEffect, a notification or a dialog cannot be pushed from outside a command — so
the shape a chat, a counter in a sidebar or a "your account was blocked" takes is an in-process event: every
controller that cares subscribes in OnInitializeAsync, unsubscribes in OnDispose, and answers an event by
calling InvokeAsync on its own runtime to change its own state. examples/TeamRoom (Services/AppEvents.cs,
Controllers/TeamRoomController.cs) is the worked example; a runtime that has stopped between the event and the
push throws InvalidOperationException, which the subscriber catches and ignores.
Disposal
Dispose() calls Dispose(bool disposing), which detaches the change notifier and calls OnDispose() once;
override OnDispose() to release resources a controller owns — a subscription, a timer, anything that must
not outlive the runtime.
Where to go next
- Binding for the paths a
[RecursiveMember]property is addressed by. - Interactions and effects for what a command's effects can do and how a failed command is reported.
- Views for the component tree a controller's state feeds.