NE.Standard

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.