Runic.Application 0.7.0-preview.3

Suggested Alternatives

Runic.Application.Views

Additional Details

Renamed to Runic.Application.Views in 0.7.0-preview.7. Namespaces are unchanged; replace the package reference.

This is a prerelease version of Runic.Application.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package Runic.Application --version 0.7.0-preview.3
                    
NuGet\Install-Package Runic.Application -Version 0.7.0-preview.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Runic.Application" Version="0.7.0-preview.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Runic.Application" Version="0.7.0-preview.3" />
                    
Directory.Packages.props
<PackageReference Include="Runic.Application" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Runic.Application --version 0.7.0-preview.3
                    
#r "nuget: Runic.Application, 0.7.0-preview.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Runic.Application@0.7.0-preview.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Runic.Application&version=0.7.0-preview.3&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Runic.Application&version=0.7.0-preview.3&prerelease
                    
Install as a Cake Tool

Runic.Application

Typed .NET Windows and Views with generated TypeScript clients. C# ViewModels own application state, commands, and operation lifetimes; a React, Vue, Svelte, Angular, or plain TypeScript frontend renders them through a generated client. The package's types are in the Runic.Application.Views namespace.

Install

Reference a host adapter; it brings this package and its build targets:

dotnet add package Runic.Application.CsWebUi --prerelease   # CS-WebUI browser or WebView window
dotnet add package Runic.Application.Desktop --prerelease   # or: Runic Desktop native host
dotnet add package CommunityToolkit.Mvvm                     # or ReactiveUI with Runic.Application.ReactiveUI
dotnet add package Microsoft.Extensions.DependencyInjection  # ServiceCollection; the adapters need only the abstractions

To start a new application, run the guided creator (dnx Runic.Create@<VERSION>) or the dotnet new runic-app template, then dotnet tool restore and dotnet runic dev.

A minimal Window

In a project named MyApp:

// Counter.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Runic.Application.Views.CsWebUi;

namespace MyApp;

public sealed partial class CounterViewModel : ObservableObject
{
    [ObservableProperty] private int count;

    [RelayCommand]
    private void Increment() => Count++;
}

// Selecting the Window makes the build generate Frontend/src/generated/counter.ts.
public sealed partial class CounterWindow(CsWebUiBridgeWindow<CounterViewModel> host)
    : CsWebUiWindow<CounterViewModel>(host);
// Program.cs
using CsWebUi;
using Microsoft.Extensions.DependencyInjection;
using MyApp;
using Runic.Application.Views.CsWebUi;

var services = new ServiceCollection();
services.AddScoped<CounterViewModel>();
services.AddRunicViews();
using var provider = services.BuildServiceProvider();

await using (var window = provider.OpenWindow<CounterWindow, CounterViewModel>(host => new CounterWindow(host)))
{
    window.SetRootFolder(Path.Combine(AppContext.BaseDirectory, "www"));
    window.Show("index.html");
    WebUiApplication.Wait();
}
WebUiApplication.Clean();
import { connectCounter } from "./generated/counter.js";

const counter = await connectCounter();
counter.subscribe(state => { document.querySelector("#count")!.textContent = String(state.count); });
document.querySelector("#increment")!.addEventListener("click", () => void counter.increment());

Generated modules import the shared browser runtime, so the frontend installs @runic-artifex/views (npm install @runic-artifex/views@preview) and bundles its entry. connectCounter() returns a CounterClient; subscribe delivers the current state first, calls reject with the runtime's BridgeError, and dispose() releases the connection. @runic-artifex/react, @runic-artifex/vue, @runic-artifex/svelte (useView) and @runic-artifex/angular (injectView()) bind clients to component lifetimes.

Frontend/index.html loads webui.js and the host's client script (runic-cswebui.js or runic-desktop-views.js) before the module. The First Window example is this application with a complete frontend.

Windows, Views, and composition

A partial RunicWindow<TViewModel> declares a window and its root ViewModel; a partial RunicView<TViewModel> selects the content presented for a ViewModel property. [RunicViewContract("name")] adds a second View for the same ViewModel. The build compiles the application, inspects these classes after the MVVM source generators run, and generates C# attachments and one ordinary TypeScript module per ViewModel.

A View is a logical .NET presentation object with typed DataContext. Its browser counterpart can be a plain TypeScript function or a framework component. The browser acknowledges mount and unmount, so a content session creates a fresh View for each presentation and releases it when the outlet changes. The ViewModel belongs to its application or window DI scope and may survive View changes. Window-local routes and operation admission are host-neutral; native window creation belongs to the host adapter.

The generated composition class (<ProjectName>.RunicBridgeComposition, or RunicBridgeCompositionType) provides two registrations:

  • AddRunicViews() registers the generated Bridges, every non-Window View as transient, and ServiceProviderViewLocator as the default IRunicViewLocator. Register an earlier IRunicViewLocator or View to replace a default.
  • AddRunicBridges() registers only the Bridges, for applications that create Views themselves or use another locator, such as ReactiveRunicViewLocator.

ViewModels stay explicit registrations because their lifetime is an application decision; a Window's root ViewModel must be scoped. The registrations are generated code, so they need no reflection and are NativeAOT-safe.

Presented content is bound to the window's model context, and keeps its checked-field registries, only while it is attached. Replacing, clearing, or pruning it releases both; the window keeps a weak identity so presenting the same object again returns the same reference. When the browser still shows a suspended reference, as after Main = b; Main = a; in one command, its mount resumes on the re-attached bridge with a fresh View. Snapshot revisions increase across the whole window, so a re-attached route never publishes an older revision. The wire protocol describes these rules.

Generated clients expose a snapshot, subscriptions, typed property setters, commands, and disposal. Calls reject with typed errors. Accepted operations remain owned by .NET across a browser reload; a reconnect gets a new snapshot. A transport failure after acceptance can leave completion unknown, so callers should inspect state before retrying a non-idempotent command. The generated field-write API returns receipts and version conflicts; frontend form helpers must await pending edits before issuing Save.

The runtime emits state and replies with generated and explicit Utf8JsonWriter code. The build tool inspects the compiled application; release runtime serialization does not reflect over ViewModel members. The first-window example is exercised through Native AOT and Chromium in CI. The generator's errors are listed under Generator diagnostics.

The First Window, CommunityToolkit Notes, and Reactive Notes examples exercise the packaged graph and generated client.

Generated TypeScript

Each ViewModel gets a module named after it (EditorViewModel becomes editor.ts) with its state interface, client interface and connect function. Every enum, DTO and union in the contract becomes one exported type named after its C# type, declared once in types.ts beside the modules. Next to each client, editor.mock.ts exports a typed test double, mockEditor; see Testing. Applications import it only from tests, so it is not bundled. Each module imports the named types it uses and re-exports them, so import type { DocumentPane } from "./generated/document.js" works too:

/// <summary>A pane of the document page.</summary>
public enum DocumentPane { Editor, Preview }

/// <summary>The pane the document currently shows.</summary>
public DocumentPane ActivePane => ...;
/** A pane of the document page. */
export type DocumentPane = "Editor" | "Preview";
  • An enum is a union of its wire names (the C# names, or [RunicAlias]), so a switch over it can be exhaustive.
  • A DTO is an interface with its wire member names. A [RunicUnion] is a union of its cases, discriminated by $case.
  • Two C# types with the same name, or a name that collides with a generated declaration such as EditorState, are qualified with their namespace segments, for example NotesItem and TasksItem. Names are stable while no new collision appears: adding a same-named type, even in another ViewModel, qualifies the existing one too. Import the name your frontend uses and expect a compile error, not a silent change, when that happens. Generic DTOs append their arguments, for example PageOfNoteRow or BoxOfArrayOfInt32, and an enum without cases is never. A C# type named like a TypeScript reserved word is qualified the same way.
  • A generic DTO member declared T is nullable where the type argument is: value: string in Box<string> and value: string | null in Box<string?>, and the generated .NET reader rejects null for the former. A member declared T? is always nullable. Members inherited from a generic base class follow the derived type's arguments, and a type without annotations, such as [RunicCommandInput(typeof(Box<string>))] or a union case, is non-nullable throughout. Uses whose members differ get separate declarations, for example BoxOfString and BoxOfNullableOfString; a type used only as Box<string?> keeps the plain name BoxOfString.
  • A content property is the union of the PageReference types of the ViewModels it can present, such as DocumentPageReference | HomePageReference, with | null when it is nullable. A NavigationRegion<TContent> slot always includes | null, because a region can be empty; see Navigation.
  • <summary> comments on ViewModels, state properties, commands, interactions, DTO types and members, and enum cases become TSDoc. The bootstrap pass writes the XML documentation file for this; a project that already writes one keeps its own. Set RunicBridgeDocumentation=false to skip it. A separate model assembly (RunicBridgeModelAssembly) needs its XML documentation file beside it.
  • A command argument keeps the parameter name of its CommunityToolkit [RelayCommand] method. Other commands name it after the argument's C# type, or input for scalars.

Generator diagnostics

The generator reports every ViewModel's first problem in one build. An error names the type and member, and points at its source line when the inspected assembly has a portable PDB (the default). A member generated by a source generator, such as a CommunityToolkit [ObservableProperty], points at its [RelayCommand] method or its declaring class.

ID Problem Fix
RUNICBRIDGE001 Invalid generator invocation or build configuration, such as a malformed RunicBridgeCompositionType; also an internal generator error. Correct the build property. Report an internal error with the ViewModel that triggers it.
RUNICBRIDGE002 A CommunityToolkit ObservableValidator ViewModel in a Native AOT publish. Publish framework-dependent until its validation is verified under AOT.
RUNICBRIDGE003 A state, command, or interaction value type is not a supported bridge value. The message names the member path, for example EditorViewModel.Current.value. Use a supported scalar, collection, public DTO, [RunicUnion] or [RunicBridgeCodec] type.
RUNICBRIDGE004 Two generated names collide: ViewModel names, presentation kinds, state wire names, the reserved revision and validation fields, interactions, routes, client members, or generated files (including a hand-written types.ts). Rename one member, or set a wire name with [RunicAlias].
RUNICBRIDGE005 The model assembly or one of its dependencies could not be loaded. Check the bootstrap output and package versions.
RUNICBRIDGE006 The assembly has no Window or View class, one is not public, top-level, concrete and closed, or a View contract is invalid, duplicated or missing. Make the class public and top-level; give each [RunicViewContract] a unique letters-and-digits name.
RUNICBRIDGE007 A ViewModel does not implement INotifyPropertyChanged, is not a public top-level class, or has no state, command or interaction. Change the ViewModel declaration.
RUNICBRIDGE008 A state property has no public getter or an empty wire name, ViewModel content or a NavigationRegion<TContent> slot has a public setter, a ViewModel collection's item type has no registered View, or a region slot is a NavigationRegion<object> or has no ViewModel with a registered View that is a TContent. Add a getter, make content read-only to the web view, register a View for the item or content type, or give the region a specific content interface or base class.
RUNICBRIDGE009 A command's name does not end with Command, its shape is unsupported, or a non-ReactiveUI command has [RunicCommandResult]. Rename the command or use a supported CommunityToolkit, ReactiveUI or [RunicCommandInput] command.
RUNICBRIDGE010 A [RunicCollection] member is not a read-only, non-nullable collection of DTO rows, or its key is not a non-nullable string, Guid or Int32 row property. Change the collection or its key.
RUNICBRIDGE011 A ReactiveUI interaction has no public getter or has a public setter. Expose the interaction as a get-only property.
RUNICBRIDGE012 A [RunicFailure] is on a member that is not a Bridge command or its [RelayCommand] method, is on both the property and the method, or names object, an exception or Nullable<T>. Declare one failure type per command: any Bridge value type, typically a DTO, an enum or a [RunicUnion]. A failure type the Bridge cannot encode is RUNICBRIDGE003 at {Model}.{Command}.failure.

Build properties

The build targets ship in this package and apply to every project that references a host adapter. A project needs none of these properties unless it departs from the conventional Frontend folder.

Property Default Purpose
RunicBridgeFrontendDir $(MSBuildProjectDirectory)/Frontend Frontend package directory.
RunicBridgeTypescriptDir <frontend>/src/generated Generated TypeScript clients.
RunicBridgeFrontendPackageManager packageManager in package.json, then pnpm-lock.yaml, bun.lock, else npm Selects the default build and install commands.
RunicBridgeFrontendBuildCommand npm run build, pnpm run build, or bun run --bun build Production frontend build.
RunicBridgeFrontendInstallCommand npm ci, pnpm install --frozen-lockfile, or bun install --frozen-lockfile, each with --ignore-scripts Runs when node_modules is missing.
RunicBridgeInstallFrontend true Set false when a workspace install owns the frontend packages.
RunicBridgeBuildFrontend true Set false when another tool owns the frontend build. TypeScript is still generated.
RunicBridgeCopyFrontend true Copies <frontend>/dist to www/ in the build output. Publish always copies it.
RunicBridgeCompositionType <project name>.RunicBridgeComposition (host adapters) Generated composition class.
RunicBridgeModelAssembly the project itself Inspect a separately built ViewModel assembly instead of a bootstrap build.
RunicBridgeReactiveUiFlavor none primitives or reactive for ReactiveUI projects.

dotnet build and dotnet publish copy the built frontend as loose files to www/ next to the executable; they do not embed it. dotnet runic dev sets RunicBridgeBuildFrontend=false and RunicBridgeCopyFrontend=false while its development server serves the frontend. The dotnet runic README lists the development-server properties it reads.

A single-project application is compiled twice: a bootstrap pass with an empty generated composition, which the generator inspects, then the real build with the generated code. RunicBridgeBootstrap is true only in the bootstrap pass.

Typed commands and checked writes

CommunityToolkit IRelayCommand<T> and IAsyncRelayCommand<T> use the same supported input shapes and generated codecs as ReactiveUI commands: scalars, nullable values, DTOs, collections, unions, and explicit custom codecs. Generated canX(input) queries use the actual parameter. Async Toolkit commands expose startX, completion, recovery, and cancellation, with no invented result value. Toolkit cancellation calls the command's Cancel() and therefore targets its current execution; it does not isolate concurrent invocations of the same command instance.

Checked-write receipts decode their values just like state: for example, an Int64 receipt's snapshot.value or conflict's incoming.value is a bigint. An identical request ID and payload replays its receipt. Reusing an ID with a different baseline version, baseline value, or new value produces a conflict. Each field retains at most 64 receipts within a default 256 KiB encoded-value and receipt-metadata budget. Evicted or oversized receipts leave bounded request-ID tombstones; reconcile authoritative state before issuing a new ID. The write may have succeeded even when its receipt could not be retained.

Batch synchronous model updates

using (BridgeSnapshotBatch.Begin(document))
{
    document.Title = imported.Title;
    document.Content = imported.Content;
    document.Language = imported.Language;
}

Nested scopes on the same model capture and publish one final snapshot per attached bridge when the outer scope ends. Notifications still advance the revision, and direct command/checked-write replies capture current state immediately. A batch controls snapshot work; it does not make mutations atomic or own the model scheduler. Enter the model's execution context as usual and keep the batch around synchronous changes, after awaiting I/O. The translations editor uses this during bulk document reconciliation and command result updates.

Structured validation

Models and nested DTOs may implement INotifyDataErrorInfo. Generated snapshots then expose validation.hasErrors, validation.truncated, and validation.errors, alongside existing property error string arrays. Each error contains a path of wire property names, list indexes, or dictionary keys and a message. Root entity errors use an empty path. Aliases are preserved, so a postal-code error might have the path ["profile", "postal-code"].

Return a BridgeValidationMessage from GetErrors to supply a stable Code, Severity, or relative CLR MemberPaths. Ordinary strings and ValidationResult remain supported. Generated metadata resolves paths without runtime reflection. Traversal and messages are bounded; truncated reports when validation details are incomplete. Nested ErrorsChanged notifications publish new validation state, and removed objects lose their subscriptions. This projection works across model frameworks; it does not run validation rules itself.

Use [RunicIgnore] on validation-only DTO properties such as a computed HasErrors; their values are represented by the validation projection.

Failure detail in development

A command, setter, checked write or operation that throws replies with a bounded message such as "Save failed." and is logged (see Logging and telemetry). In development the error also carries detail: {type, message, stack}, which the TypeScript runtime exposes as BridgeError.detail and the Vite plugin shows in DevTools. Development means DOTNET_ENVIRONMENT (or else ASPNETCORE_ENVIRONMENT) is Development, which dotnet runic dev sets. Set BridgeDiagnostics.IncludeFailureDetail to true or false to choose explicitly. Detail can contain file paths and application data, so do not enable it for a distributed build.

Declared failures

A command declares its expected failure type with [RunicFailure(typeof(SaveFailure))] on the command property or on its CommunityToolkit [RelayCommand] method, and signals it by throwing RunicFailureException(new TitleRequired()). The Bridge replies domain-failed with the encoded failure, in every environment and never with detail; an operation ends with the domain-failed status. Use [RunicUnion] for several cases. A value that is not the declared type, one that cannot be encoded, or one over 4 KiB is reported as an ordinary failure (event 1008). A synchronous command reports only failures thrown synchronously from Execute, so an async void plain ICommand cannot declare a failure that it throws after its first await. Operations exist only for asynchronous CommunityToolkit and ReactiveUI commands.

[RunicUnion(typeof(TitleRequired), typeof(TitleTaken))]
public abstract record SaveFailure;
[RunicUnionCase("titleRequired")] public sealed record TitleRequired : SaveFailure;
[RunicUnionCase("titleTaken")] public sealed record TitleTaken(string ExistingTitle) : SaveFailure;

[RelayCommand, RunicFailure(typeof(SaveFailure))]
private async Task SaveAsync(CancellationToken token)
{
    if (string.IsNullOrWhiteSpace(Title)) throw new RunicFailureException(new TitleRequired());
    // ...
}

The generated client's save() then returns Promise<BridgeOutcome<EditorState, SaveFailure>> instead of Promise<EditorState>, its operation is BridgeOperation<void, SaveFailure>, and the generated mock encodes the failure. A ReactiveUI command also reports the failure on ThrownExceptions, so give every bridged ReactiveCommand a subscriber.

RunicNavigator owns typed navigation regions. Each region gives its entries stable ids, and it supports awaited departure guards, initialize-once and resume-on-return hooks, and owned or borrowed content. The API is experimental: every type is marked [Experimental("RUNICNAV001")]. Suppress RUNICNAV001 to use it, and expect changes before it is supported. The navigator replaces ReactiveUI's RoutingState; it does not wrap one. See the ReactiveUI adapter for observables and a back command.

services.AddRunicNavigation(); // one model context and navigator per window scope

var main = navigator.CreateRegion<IMainViewModel>(this, NavigationTarget.Borrow<IMainViewModel>(home));
var result = await main.PushAsync(NavigationTarget.Create<IMainViewModel>(services => new DocumentViewModel(
    services.GetRequiredService<EditorViewModel>())));
if (result is NavigationResult<IMainViewModel>.Committed) { /* main.Current is the document */ }
await main.BackAsync(); // the document retires and is disposed; home resumes

AddRunicNavigation() registers a scoped IRunicModelContext and a scoped RunicNavigator that uses it. The navigator is only IAsyncDisposable, so create window scopes with CreateAsyncScope and dispose them with DisposeAsync. Without DI, construct the navigator with RunicNavigatorOptions (ModelContext, Services, LoggerFactory, TimeProvider, CloseTimeout), and use the same model context for the window's session.

Operations and results

Operation Effect
PushAsync The current entry is retained, and the new entry becomes current.
BackAsync The current entry retires, and the top retained entry resumes. With fewer than two entries the request is Rejected(NoHistory).
BackToAsync(id) Every entry above the target retires, and the target resumes.
ReplaceAsync The current entry retires, and the history is unchanged.
ResetAsync Every entry retires, and the new entry becomes the root.
ClearHistoryAsync The retained entries retire, and the current entry stays.
ClearAsync Every entry retires, and the region becomes empty.
  • Every operation returns a NavigationResult<TContent>. The outcomes are returned, never thrown:

    • Committed, with the new current entry and the retired entries.
    • Rejected, with a Reason: Guard, Cancelled, Closed, Reentrant, NoHistory, NotCurrent or EntryNotFound.
    • Failed, with the Phase (Guarding, Preparing or Committing) and the exception (Error).
    • Superseded.

    Operations throw only for argument and ownership errors.

  • NavigationRequestOptions.ExpectedCurrent makes a request conditional. It is rejected as NotCurrent if that entry is not current at admission or at commit. Use it, or NavigationEntryContext.BackAsync, for work that completes late, such as a double click or a dialog that answers after the user moved on.

  • A region exposes Current, CurrentEntry, History, CanGoBack and IsTransitioning. It raises PropertyChanged for them inside a model turn, the commit turn for a navigation.

Entry lifecycle

An entry starts Pending and becomes Active when its push commits. It then moves between Active and Retained as pushes and Backs commit, and ends Retired. Every state can retire:

Pending ──commit──▶ Active ◀──Push / Back(To)──▶ Retained
   │                  │                              │
   │ not committed    │ Back, Replace,               │ BackTo, Reset,
   │                  │ Reset, Clear                 │ ClearHistory, Clear
   ▼                  ▼                              ▼
                    Retired

A closing parent region or navigator disposal also retires entries in any state.

State Meaning
Pending A push is preparing the entry. It is not in the region yet.
Active The entry is the region's Current.
Retained The entry is in the history below Current and keeps its content, draft included.
Retired The entry has left the region, or a push of it never committed. It never becomes active again.

Retirement runs once per entry, whichever path reaches it first: commit cleanup, a closing parent, a failed preparation or navigator disposal. It runs these steps in order, and each step runs even if an earlier one failed:

  1. NavigationEntryContext.Retirement is cancelled.
  2. The child regions that the entry's content owns close. Their in-flight transitions end as Rejected(Closed), and their entries retire depth-first.
  3. Owned content only: its presentations are forgotten.
  4. Owned content only: the content is disposed, outside model turns.
  5. Owned content only: its model-context lease is released.

A failed step is logged as event 1064.

Transitions

Phase Where What happens
Admission Synchronously, in the call The request is rejected at once for these reasons: Closed; Reentrant, when the caller is inside a hook of a transition in this region, an ancestor or a descendant, for example a CurrentPane guard that calls Main.BackAsync(); NotCurrent; NoHistory; or Cancelled, for a token that is already cancelled. Otherwise the request supersedes every earlier request of the region that has not started committing. It also supersedes those of the child regions its plan affects.
Guarding Outside model turns The request first waits until earlier requests of the region, and those of the child regions in its plan, have ended. Then departure guards run one at a time, deepest first, also when a push only retains the current entry (NavigationDepartureKind.Retain). false rejects the request as Guard. A guard that throws fails it (event 1060).
Preparing Outside model turns A new entry runs its factory, the ownership check, model-context binding and InitializeAsync, exactly once. A resumed entry runs ResumeAsync, which runs again on a retry. A step that throws fails the request (event 1061), and its pending entry retires.
Committing One model turn The turn re-checks supersession, ExpectedCurrent, the target and the versions of the affected regions. Then it applies the new stacks and child policies and raises PropertyChanged. A handler that throws is logged (event 1063; its Property names the region property), and the commit stands. If the turn cannot run, for example because the model context throws, the request fails as Failed(Committing) (event 1062), nothing changes, and its pending entry retires.
Committed Outside model turns Admission is released, and departing entries retire. The returned ValueTask completes after that cleanup.
  • A request is superseded only before it commits, never after. A cancelled token gives Rejected(Cancelled) and navigator disposal gives Rejected(Closed), in any phase before the commit.
  • Hook tokens are cancelled when the request is superseded, cancelled or closed. A superseded request that is still running 5 seconds later is logged once (event 1067). The next request waits for it.
  • Admission inside a model turn does not block. The transition continues on the thread pool, so no hook runs in the caller's turn. Don't block on its result in that turn.
  • Admission is per region, so a guard in one region may await a request in a sibling region, as the confirm dialog below does. Parent and child regions coordinate through the plan: a child commit during the parent's guard supersedes the parent's request.
  • One entry's guard never runs twice at the same time. It can still run again for the same departure: a parent transition that retires a child region asks the child's guards even when a child transition already asked them. Write guards to be repeatable; the user may see the same prompt twice.

Ownership

Target Who creates the content On retirement
Borrow(content) The caller or a container Nothing: the content is never disposed or forgotten, and the regions it owns are left alone.
Own(content) The caller, with new, handing it over Child regions close, presentations are forgotten, the content is disposed and its lease is released.
Create(factory) / Create(factory, input) The factory, with new, when the transition prepares As Own.
  • Content resolved from a container (a window scope, the root provider or Splat) belongs to that container, so Borrow it. Use Own and Create only for content constructed with new. The factory receives the window's IServiceProvider to resolve constructor dependencies. Those dependencies stay container-owned, so don't resolve disposable transients for it.
  • An instance can be owned once. Own of an instance the navigator has already owned, whether live or retired, throws InvalidOperationException. A Create factory that returns one gives Failed(Preparing).
  • Present owned content only through its region's slot. Retirement forgets every presentation of the instance.
  • Owned content is disposed outside model turns. A Dispose that touches shared model state uses ModelContext.InvokeAsync, and it must not await navigation on the same navigator.
  • A region's owner can be any object. Regions owned by an owned entry's content are that entry's children, and they close when it retires. NavigationRegionOptions.WhileParentRetained decides what a child region does while its parent entry is retained: Keep (the default), ResetToRoot or Clear. Regions owned by borrowed content or by the root close only with the navigator.
  • An initial target (CreateRegion(owner, initial)) becomes current without a transition. Its content must not implement INavigationInitialize or INavigationInitialize<TInput>, and Create(factory, input) cannot be an initial target.

Content slots

Expose a region as a get-only NavigationRegion<TContent> property, and the generator treats it as a content slot. The snapshot presents Current, or clears the slot when the region is empty. The wire value and TypeScript type are those of a nullable content property (for example DocumentPageReference | HomePageReference | null), so moving a non-null content property to a region adds | null, and the frontend must render the empty state. TContent must be an interface or base class of ViewModels with registered Views. A NavigationRegion<object> slot, or one with a public setter, is RUNICBRIDGE008.

The generated Bridge observes the region's Current and binds the window session to the navigator, so retiring owned content forgets its routes. A navigator serves one window, and the session must use the navigator's model context. Nothing is forwarded by hand, and ViewOutlets and test drivers (View<T>(vm => vm.Main)) work unchanged. Going back presents the same PageReference id with the retained model, and the outlet mounts a fresh View. Browser reload, remount and disconnect never touch entries.

Stale-route window. A commit retires and forgets departing owned content right away, while the frontend sees the new Current only with the next state capture. Until then it may still hold the old reference. An invocation on that reference is rejected like any forgotten route, and the outlet may briefly show the old View or nothing. The next capture converges.

Results from an entry

PushForResult<TResult>(target) pushes an entry and returns a NavigationResultRequest:

  • Transition is the push's result.
  • Completion ends Completed(value) only when the entry calls NavigationEntryContext.CompleteAsync(value) and the Back that it issues commits.
  • Completion ends Dismissed on every other path: the push does not commit, the entry retires another way, the caller's token is cancelled after the commit (which also goes back from the entry), or the navigator closes.

A Back from CompleteAsync or from caller cancellation may leave the region empty, so a dialog region can start empty. A push onto an entry whose caller cancelled retires that entry instead of retaining it. That Back can still be rejected, for example when a push over the entry is already committing or the entry is no longer on top. Then the dismissed entry stays in the history: a later Back resumes it, and its CompleteAsync goes back, possibly emptying the region, and drops the value (event 1071).

A rejected CompleteAsync, for example one vetoed by a guard, leaves the request open, and the entry can try again. CompleteAsync accepts any value of the request's result type at run time, so true completes a bool? request and 5 an object request. Completions run their continuations asynchronously, after the commit turn. A completion is set when the entry starts retiring, so it can be observed while the entry's owned content is still being disposed.

The confirm example below follows the Notes example. Its guard only decides. Guards can run again for the same departure, and a guard's true is not a commit, because a later request can still supersede the Back. So the guard reads the draft on a model turn and asks again on each run, and the draft is discarded only when Main leaves the document, in the commit turn. The code that starts the Back forgets the guard's yes when the Back ends without committing, so a later departure that runs no guards, such as the window closing, keeps the draft. Cancel must always end the question. If its CompleteAsync is rejected, for example because something was pushed over the confirm, the confirm cancels a token that the guard linked into the request. That dismisses the request instead.

// The document's departure guard asks in a sibling Dialog region.
public async ValueTask<bool> CanDepartAsync(NavigationDeparture departure, CancellationToken token)
{
    _discardOnDeparture = false;
    if (departure.Kind != NavigationDepartureKind.Retire) return true;
    if (!await _context.InvokeAsync(() => Editor.IsDirty)) return true; // guards run outside model turns
    var confirm = new ConfirmNavigationViewModel("Discard the unsaved edits?");
    using var answer = CancellationTokenSource.CreateLinkedTokenSource(token, confirm.Dismissal);
    var request = dialog.PushForResult<bool>(NavigationTarget.Own<IDialogViewModel>(confirm),
        cancellationToken: answer.Token);
    if (await request.Completion is not NavigationCompletion<bool>.Completed { Value: true }) return false;
    _discardOnDeparture = true; // discard when the Back commits, not here
    return true;
}

// In the workspace: the caller of the Back forgets the yes when the Back is superseded,
// rejected or fails.
public async Task OpenHomeAsync()
{
    var leaving = main.Current as DocumentViewModel;
    var committed = false;
    try { committed = await main.BackAsync() is NavigationResult<IMainViewModel>.Committed; }
    finally { if (!committed) leaving?.ForgetConfirmedDeparture(); } // also when Back throws
}

// In the document:
internal void ForgetConfirmedDeparture() => _discardOnDeparture = false;

// Main's PropertyChanged handler runs in the commit turn.
private void OnMainChanged(object? sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName == nameof(main.Current) && _discardOnDeparture && !ReferenceEquals(main.Current, this))
    { _discardOnDeparture = false; Editor.DiscardChanges(); }
}

// The confirm captures its entry in InitializeAsync and answers with it.
public ValueTask InitializeAsync(NavigationEntryContext entry, CancellationToken token)
{ _entry = entry; return ValueTask.CompletedTask; }
private Task Confirm() => _entry.CompleteAsync(true).AsTask();
private async Task Cancel()
{
    if (await _entry.CompleteAsync(false) is not NavigationResult<object>.Committed)
        await _dismissal.CancelAsync(); // _dismissal.Token is Dismissal
}

Window close and disposal

DI disposes the navigator before the model context it depends on. Its DisposeAsync:

  1. Refuses new requests and dismisses every open PushForResult request, so guards that await a result unblock.
  2. Cancels in-flight transitions and waits for them, and for running cleanup, up to CloseTimeout (10 seconds on its TimeProvider). A late transition never commits.
  3. Retires every remaining entry, region by region, pending entries included. Clearing a closing region normally runs in a model turn. These turns share one CloseTimeout, so disposal takes about twice CloseTimeout at most, plus owned content disposal. If a turn does not run in time, the stack is cleared outside a turn (event 1068).

Window close does not run guards. Disposing a navigator cancels window-owned operations that its owned content started, and then disposes that content.

WhenIdleAsync() waits until no transition or retirement is running.

Pitfalls

  • A hook that disposes its own navigator stalls disposal for CloseTimeout, because disposal waits for the transition that runs the hook.
  • A hook that awaits WhenIdleAsync() deadlocks: the navigator is not idle while that hook runs.
  • A child guard that commits a change in its own region supersedes the parent's transition, because the parent's commit sees the child's version change.
  • A request from a hook into the same region, one of its descendants or one of its ancestors is Rejected(Reentrant). For example, a parent's InitializeAsync cannot push into its own child region, and a CurrentPane guard cannot call Main.BackAsync() on the region that holds its document. Issue the request after the transition commits.

Logging

Navigation logs events 1060-1071 under Runic.Application.Views (see Logging and telemetry). Errors and warnings carry the exception. Rejections, supersessions and dismissed results log at Debug. The properties are Region (the TContent type name), RegionId (a per-navigator number that tells apart regions with the same TContent), Operation, EntryType, Step, Reason, Property (event 1063, the region property whose handler threw) and ErrorType, never values. The reserved ranges are listed under Logging and telemetry.

Logging and telemetry

OpenWindow (CS-WebUI) and OpenDesktopWindowAsync (Desktop) pass the scope's ILoggerFactory to the Window's WindowContentSession when one is registered, for example with services.AddLogging(...). A session constructed directly takes it as loggerFactory. Without a factory, each entry is written to System.Diagnostics.Trace (TraceError, or TraceWarning for warnings) as its formatted message followed by the exception; a failed View mount goes to standard error instead.

Every entry carries the exception, in every environment: logs belong to the operator, whereas BridgeDiagnostics governs only what a Bridge reply shows to web content. Exception messages can contain application data, so treat log output accordingly. The message properties are listed per event.

Event ID Name Level Logged when Properties
1000 BridgeCommandFailed Error A command throws. Model, Member, Route, ErrorType
1001 BridgeSetterFailed Error A property setter throws. Model, Member, Route, ErrorType
1002 BridgeFieldWriteFailed Error A checked field write throws. Model, Member, Route, ErrorType
1003 BridgeOperationAdmissionFailed Error Admitting an operation throws. Model, Member, Route, ErrorType
1004 BridgeOperationFailed Error An admitted operation throws. Member, ErrorType
1005 BridgeOperationCancellationCallbackFailed Warning A cancellation callback throws while a Window closes. ErrorType
1006 BridgeCommandDomainFailed Debug A command throws its declared failure. Model, Member, Route, FailureType
1007 BridgeOperationDomainFailed Debug An admitted operation throws its declared failure. Member, FailureType
1008 BridgeDomainFailureNotEncoded Error A RunicFailureException from a command or operation is undeclared, cannot be encoded or is over 4 KiB, so it is reported as failed. When the encoder threw, the entry carries both exceptions. Model, Member, Route, FailureType, Reason
1010 BridgeSnapshotCaptureFailed Error A state snapshot writer throws. Model, Route, ErrorType
1011 BridgeSnapshotDeliveryFailed Error A host rejects a state or delta frame. Model, Route, ErrorType
1012 BridgeCollectionKeysRejected Error A [RunicCollection] has a null row or a null, empty or duplicate key, so the route withholds its state. Model, Field, Key, Route
1020 ViewMountFailed Error A .NET View fails to mount. Route, ErrorType
1021 ViewRemountFailed Error A View fails to mount again after a reconnect. Route, ErrorType
1030 ModelTurnFailed Error A posted model-context turn throws. ErrorType
1031 ModelTurnDropped Warning Disposal drops a posted turn. ErrorType
1032 UnhandledTurnHandlerFailed Error An UnhandledTurnException handler throws. ErrorType
1033 ModelContextReleaseFailed Error Releasing a model context fails in the background. ErrorType
1040 RoutedRegionRouteIncompatible Error A ReactiveUI ReactiveRoutedRegion<T> receives a ViewModel that is not a T, so it presents no content. Region, Model
1041 RoutedRegionRouterFailed Error The router observed by a ReactiveRoutedRegion<T> fails; the region keeps its last content. Region, ErrorType
1042 ReactiveCommandFailed Error A ReactiveUI command or object observed with ObserveBridgeExceptions(logger) reports an exception other than a declared RunicFailureException or a cancellation on ThrownExceptions. Source (the command expression or sourceName), ErrorType
1060 NavigationGuardFailed Error A navigation departure guard throws; the transition fails. A close of the navigator or region is rejected as Closed and does not log it. Region, RegionId, Operation, EntryType, ErrorType
1061 NavigationPreparationFailed Error A navigation factory, the ownership check, binding, initialize or resume throws. A close of the navigator or region is rejected as Closed and does not log it. Region, RegionId, Operation, EntryType, ErrorType
1062 NavigationCommitFailed Error A navigation commit turn cannot run. Region, RegionId, Operation, ErrorType
1063 NavigationNotificationFailed Error A navigation region PropertyChanged handler throws; the commit stands. Region, RegionId, Property, ErrorType
1064 NavigationEntryCleanupFailed Error A retirement step (Retirement, Children, Forget, Dispose, Lease), the clearing of a closed region (Close) or the cancellation of transitions (Cancel, a throwing cancellation callback) fails; later steps still run. EntryType is None for Close and Cancel. Region, RegionId, EntryType, Step, ErrorType
1065 NavigationTransitionRejected Debug A navigation request is rejected. Region, RegionId, Operation, Reason
1066 NavigationTransitionSuperseded Debug A later request supersedes a navigation request. Region, RegionId, Operation
1067 NavigationSupersededTransitionOverrun Warning A superseded navigation request is still running 5 seconds after supersession. Region, RegionId, Operation
1068 NavigationCloseTimedOut Warning Closing a navigation region timed out waiting for a model turn; its state was cleared outside a turn. The clearing turns of one disposal share one close timeout, so a disposal takes about twice CloseTimeout at most. Region, RegionId
1069 NavigationInitializeTimedOut Warning Retiring an entry stopped waiting for its running initialize hook after the close timeout and disposed the content while the hook runs. During disposal the wait is skipped once the wait for cancelled transitions timed out. Region, RegionId, EntryType
1070 NavigationResultDismissed Debug A PushForResult request ends Dismissed. Reason is NotCommitted (its push did not commit), Retired (its entry retired without CompleteAsync), Cancelled (the caller's token was cancelled after the commit) or Closed (the navigator closed). Region, RegionId, Reason
1071 NavigationResultDropped Debug CompleteAsync went back from an entry whose request had already ended, so its value was dropped. Region, RegionId
1050 CsWebUiWindowRegistrationMissing Error A CS-WebUI Window's generated Bridge is not registered. Code, DiagnosticMessage, Remediation
2000 DesktopSnapshotDeliveryFailed Error Runic Desktop cannot run a state delivery script. Route, ErrorType
2001 DesktopWindowRegistrationMissing Error A Desktop Window's generated Bridge is not registered. Code, DiagnosticMessage, Remediation
2002 DesktopWindowCloseAfterOpen Warning RUNIC_APPLICATION_CLOSE_AFTER_OPEN=1 closed a Desktop window right after it opened. Variable
3000 WindowCloseCancellationCallbackFailed Error A Runic Desktop close-cancellation callback throws. ErrorType
3001 WindowCloseConfirmationFailed Error A Runic Desktop close confirmation throws; the Window stays open. ErrorType
3002 DesktopConfigurationInvalid Error DesktopHost.Validate finds a check that fails the window request. Code, Option, DiagnosticMessage, Remediation
3003 DesktopConfigurationLimited Warning DesktopHost.Validate finds an option the presentation ignores or narrows, or a browser fallback opens without a permission grant. Code, Option, DiagnosticMessage, Remediation
3004 BrowserLaunchStalled Warning A browser was still running but had requested nothing after ConnectionTimeout, so Runic Desktop relaunched it (DesktopHostOptions.BrowserLaunchAttempts). Attempt (the attempt that stalled), Attempts, TimeoutSeconds
3005 BridgeHandshakeExpired Warning A Bridge WebSocket sent no token check, so Runic Desktop closed it and the page reconnects: after 10 seconds, or after 2 seconds when a newer WebSocket needs the only connection. ConnectionId, SilentSeconds, Reason

Model is the ViewModel type name, Member the command or property, and Route the Bridge route (a content presentation's route is per instance, such as content12). A declared failure is an expected outcome, so it is logged at Debug, still with its exception; FailureType is the failure value's type.

Events 1000-1021, 1050 and 1060-1071 use the category Runic.Application.Views (RunicViewsTelemetry.LogCategory). Events 1030-1033 use the logger of the RunicModelContext: ILogger<RunicModelContext> when DI or a WindowContentSession with a logger factory created it, and otherwise the Trace output. Events 2000-2002 use Runic.Application.Desktop. Events 3000-3005 use Runic.Desktop and need DesktopHostOptions.LoggerFactory.

Navigation reserves events 1060-1079: 1060-1069 for transitions and cleanup, and 1070-1079 for results and later navigation events, of which 1072-1079 are not used yet. Events 1043-1049 are reserved for the ReactiveUI navigation adapter.

Events 1040-1042 come from Runic.Application.ReactiveUI (and its .Reactive flavor) and also use Runic.Application.Views. They need the ReactiveRoutedRegion<T>(router, loggerFactory) constructor, which a ViewModel can call with an ILoggerFactory injected from DI, as examples/notes-reactive-views does. Without a factory, the region writes the same messages to Trace as described above. Region is the region's ViewModel type name T. Event 1042 uses the logger passed to ObserveBridgeExceptions; Source is the observed command's expression, such as SaveCommand, or sourceName.

Bridge calls are traced by the ActivitySource and measured by the Meter named Runic.Application.Views (RunicViewsTelemetry.ActivitySourceName and MeterName). With OpenTelemetry, call AddSource(...) and AddMeter(...) with these names. Tag values are generated contract names, outcomes and exception types, never arguments or state.

Span Covers
runic.bridge.command A synchronous or awaited command.
runic.bridge.set A property setter.
runic.bridge.write A checked field write.
runic.bridge.operation.start Admitting an operation.
runic.bridge.operation Running an admitted operation.

Spans are named <kind> <Model>.<Member> and have the tags runic.bridge.kind, runic.bridge.model, runic.bridge.member, runic.bridge.route and runic.bridge.outcome. The outcome is ok, rejected, cancelled, failed, domain_failed or disconnected. An admission can also be unavailable, capacity, duplicate or expired, and an operation whose result could not be delivered is delivery_failed. A failed span has the status Error and error.type. A domain_failed call is an expected outcome: its span status stays Unset, it has no error.type, and runic.bridge.failures does not count it.

Instrument Type Unit Tags
runic.bridge.calls Counter {call} kind, model, member, outcome, error.type
runic.bridge.call.duration Histogram s kind, model, member, outcome, error.type
runic.bridge.failures Counter {failure} kind (a call kind, snapshot.capture, snapshot.delivery, collection.keys or mount), model, member, error.type
runic.bridge.snapshot.frames Counter {frame} model, runic.bridge.frame (state, delta or failure)
runic.bridge.snapshot.size Histogram By model, frame
runic.bridge.snapshot.delivery.duration Histogram s model, frame
runic.bridge.snapshot.recoveries Counter {snapshot} model
runic.bridge.snapshot.queue.depth ObservableUpDownCounter {frame} none

Route names are span tags but not metric tags, because content routes identify one instance. The duration histograms advise buckets from 0.5 ms to 10 s, and the size histogram from 256 B to 4 MiB. The queue depth reports the frames waiting for slow hosts across all Bridges when it is observed.

Incremental generation

The generator caches successful multi-view generation in its C# obj output directory. It hashes the model/dependency assemblies, generator assemblies, output-affecting options, and generated output contents. Missing or edited outputs invalidate the cache; unchanged generated files keep their timestamps. Set RUNIC_BRIDGE_CODEGEN_FORCE=1 or RUNIC_BRIDGE_CODEGEN_CACHE=0 to bypass it.

The configured frontend build command (RunicBridgeFrontendBuildCommand) runs only when one of its inputs is newer than obj/<Configuration>/<TargetFramework>/runic-bridge-frontend.stamp: files under RunicBridgeFrontendDir (excluding node_modules, dist, build, bin, obj and dot-directories), the generated TypeScript, and the project file. Generated TypeScript is rewritten only when its content changes, so C#-only edits skip the frontend build. Add RunicBridgeFrontendInput items, from a target that runs before RunicViewsGenerateBridge, for inputs outside the frontend directory such as workspace packages or generated assets; the translations editor is an example. Delete the stamp or rebuild to force a frontend build. Set RunicBridgeBuildFrontend=false when another tool, such as a running dev server, owns the frontend build; dotnet runic dev does this while its development server runs.

Generated C# and TypeScript files start with // <auto-generated />. Only such files are removed from the output directories when a ViewModel disappears, so RunicBridgeTypescriptDir may point into a directory with hand-written modules.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (4)

Showing the top 4 NuGet packages that depend on Runic.Application:

Package Downloads
Runic.Application.CsWebUi

CS-WebUI host adapter for Runic Window and View applications.

Runic.Application.ReactiveUI

ReactiveUI routing, view location, and activation for Runic Views.

Runic.Application.Desktop

Runic Window and View host adapter for Runic Desktop.

Runic.Application.ReactiveUI.Reactive

System.Reactive-flavor ReactiveUI routing, view location, activation, and scheduling for Runic Views.

GitHub repositories

This package is not used by any popular GitHub repositories.