NE.Standard

Routing

A route ties a path to a view, optionally a controller, and the metadata that governs how it is authorized, compiled and updated.

Registering a route

UIApplicationBuilder.Route has three shapes:

application.Route<CounterView>("/");
application.Route<DashboardView, DashboardController>("/dashboard");
application.Route<DashboardView, DashboardController>("/dashboard", services => new DashboardView(services));

Route<TView>(route) registers a page with no controller — nothing to bind, so no state to observe. Route<TView, TController>(route) is the usual case. A factory overload exists on both, for a view whose constructor needs services the default parameterless Activator.CreateInstance cannot supply. Every overload accepts an Action<UIRouteDefinitionBuilder> to configure the route's metadata:

application.Route<AdminView, AdminController>("/admin", route => route
    .Require(new UIAccessRule { Roles = ["Admin"] })
    .Identity("tenantId")
    .CompilationMode(UIViewCompilationMode.Lazy)
);

UIRouteDefinitionBuilder:

  • AllowAnonymous(bool value = true) — lets the route resolve with no authenticated session.
  • Require(UIAccessRule rule) — adds an access rule (roles/permissions, each Any/All) the session must satisfy; call it more than once to add several rules, all of which must pass.
  • Identity(params string[] parameters) — names which navigation parameters are part of the route's address: two visits agreeing on every named parameter share the same runtime, and any other parameter travels without splitting it.
  • CompilationMode(UIViewCompilationMode mode)Startup (compiled once at application start, the default) or Lazy (compiled on first use and cached). A view is compiled once and the compiled result shared — the HTTP shell render and the runtime's attach bind against the same CompiledView, so a page whose content varies per request must vary through the controller, not by recompiling; DefaultErrorController reading its message from a navigation parameter is the idiom.
  • ControllerUpdates(UIControllerUpdateMode mode) / ControllerUpdates(mode, TimeSpan interval) — sets whether changes are batched (default, flushed on a schedule) or pushed immediately, and the flush interval.

An explicit [UIAuthorize]/[UIAllowAnonymous] attribute on the view or controller always wins over both the builder call and UISecurityOptions.DefaultPolicy — see security.

UINavigationRequest is Route plus an optional Parameters dictionary (IReadOnlyDictionary<string, object?>). A controller reads it in OnInitializeAsync, off Context.Handle.Instance.Navigation; TryGetParameter reads one as text, or as a primitive the framework's coercion knows:

protected override Task OnInitializeAsync(CancellationToken cancellationToken)
{
    if (Context.Handle.Instance.Navigation.TryGetParameter("message", out var message))
        Message = message;

    return Task.CompletedTask;
}

Claim what separates two pages of one route. A runtime is keyed by the session, the address and the tab, and the address is the route plus the parameters named in Identity(...) — nothing else. Two tabs on /chat?c=1 and /chat?c=2 are one runtime with one state unless the route says Identity("c"); under PerClient, where the tab leaves the key, they are one runtime even in two tabs. Nothing warns: the second page simply shows the first's state. Claim the parameter, and claim only what is identity — a tracking tag claimed strands a runtime of its own.

Context.Handle.Instance also carries Id (the transport connection), WindowId (the client's window — a browser tab) and PageId. A parameter not named in Identity(...) is carried but does not separate one runtime from another.

A command returns a NavigateEffect(new UINavigationRequest { Route = "...", Parameters = ... }) to send the client to another route — the client-effect vocabulary a command's UICommandResult carries, applied after the command's own change set. Sign-in must end this way: Context.SignInAsync only marks the session id for rotation, and the rotation happens at the next shell render, so a command that signs in without navigating away leaves the old session id valid.

Not found and error views

NotFoundView<TView>(route = "/not-found") and ErrorView<TView, TController>(route = "/error") (each with a factory and a controller-backed overload) register the pages shown when a route is not registered (UIRouteNotFoundException) or an unhandled exception occurs resolving a view; both are registered anonymous. UIApplicationBuilder.Build fills in defaults for whichever of the two the application did not register itself — the framework's own DefaultNotFoundView and DefaultErrorView/DefaultErrorController (Core/Views), the latter reading its message navigation parameter the same way any other controller would.

View filters

IUIViewFilter.InvokeAsync(UIViewFilterContext context, Func<Task> next) wraps the resolution of a view — one method covering before, after, short-circuit (don't call next) and exception handling. Register one for every route with UIApplicationBuilder.AddViewFilter(filter) or AddViewFilter<TFilter>(order) (resolved from the service provider per request); attach one to a single route by putting an attribute that implements IUIViewFilter on the view or controller class, ordered by IUIViewFilter.Order (ties break global, then view, then controller). An attribute that needs constructor dependencies implements IUIViewFilterFactory instead, and is built per request through CreateFilter(IServiceProvider services).

UIViewFilterContext carries Navigation, Route, Session, Services, Phase (UIViewRequestPhase.Open/.Attach), the resolved Resolution (observation only, set after next runs), and Redirect(UINavigationRequest) to divert the request — the host re-resolves from the top, bounded the same way the error handler's redirects are.

A view filter runs twice per page load — once for the HTTP shell render and once when the client's live connection attaches — and both are real entry points; guarding only one leaves the other unlocked. That is correct for an access check and wrong for anything with a side effect, so a filter that must run once tests context.Phase. The built-in route authorization check runs outermost (Order = int.MinValue), ahead of every application and attribute filter, because a filter that ran before it could short-circuit past the check.

Command filters

IUICommandFilter.InvokeAsync(UICommandFilterContext context, Func<Task> next) is the command-side mirror, run by every command invocation: the built-in authorization check outermost, then UIApplicationBuilder.AddCommandFilter(filter)/AddCommandFilter<TFilter>(order), then attributes on the controller class and the command method (IUICommandFilterFactory for one needing services). UICommandFilterContext carries Command (IUICommandMetadata), Arguments, Handle, Route, Services, Invoked (true only once next has actually run) and Result — replace it after next to add effects, or set it directly without calling next to short-circuit. A filter cannot undo a command: once next returns, its writes are already in the change buffer.

AllowAnonymous and a command's own route

IUICommandMetadata.AllowAnonymous is bool?; null (the default, with no attribute on the command) makes the command follow UIContext.Route.AllowAnonymous — one DefaultPolicy therefore governs a page and every command on it unless a command overrides it explicitly with its own [UIAuthorize]/[UIAllowAnonymous].

See security for how access rules are evaluated and how a refusal is routed.