Runic.Application.Views.ReactiveUI
0.8.0-preview.5
dotnet add package Runic.Application.Views.ReactiveUI --version 0.8.0-preview.5
NuGet\Install-Package Runic.Application.Views.ReactiveUI -Version 0.8.0-preview.5
<PackageReference Include="Runic.Application.Views.ReactiveUI" Version="0.8.0-preview.5" />
<PackageVersion Include="Runic.Application.Views.ReactiveUI" Version="0.8.0-preview.5" />
<PackageReference Include="Runic.Application.Views.ReactiveUI" />
paket add Runic.Application.Views.ReactiveUI --version 0.8.0-preview.5
#r "nuget: Runic.Application.Views.ReactiveUI, 0.8.0-preview.5"
#:package Runic.Application.Views.ReactiveUI@0.8.0-preview.5
#addin nuget:?package=Runic.Application.Views.ReactiveUI&version=0.8.0-preview.5&prerelease
#tool nuget:?package=Runic.Application.Views.ReactiveUI&version=0.8.0-preview.5&prerelease
ReactiveUI integration for Runic Views
Runic.Application.Views.ReactiveUI is the default ReactiveUI 26 adapter for Runic
Views. It references ReactiveUI's ReactiveUI.Primitives flavor and has no
CommunityToolkit dependency. It provides:
- model-context scheduling:
AddRunicReactiveModelContext()makes commands created without a scheduler deliver on the Window's model context (see Model-context scheduling); ReactiveRunicView<T>, which implementsIViewFor<T>over a typed RunicDataContext. Windows derive fromRunicWindow<T>inRunic.Application.Viewswith ReactiveUI ViewModels too: theReactiveRunicWindow<T>base was removed in 0.7.0-preview.7, so the same Window class opens on every host with either MVVM library;ReactiveRunicViewLocator, adapting explicit ReactiveUI view mappings and contracts;ReactiveRoutedRegion<T>, projectingRoutingState.CurrentViewModelinto a generated content property, logging an incompatible route or failed router through an optionalILoggerFactory(Views events 1040-1041);- mount-owned
IActivatableViewModelleases; and - typed command and interaction adapters used by the compiled-model generator.
One browser presentation gets one logical Runic View and activation lease. A
shared ViewModel stays activated while any presentation remains mounted.
WindowContentSession.AttachPresentation creates those logical views for
generated content routes. The Reactive Notes
example covers multiple views over one ViewModel, routed content, explicit view
contracts, and activation lifetimes.
The runic-app project template uses ReactiveUI ViewModels with this adapter by
default (dotnet new runic-app; --view-models toolkit selects CommunityToolkit.Mvvm).
Primitives, not System.Reactive
ReactiveUI 26 has two flavours. This package targets the default one, built on
ReactiveUI.Primitives. If you know ReactiveUI from System.Reactive:
RxVoidreplacesSystem.Reactive.Unit: a command without input or output is aReactiveCommand<RxVoid, RxVoid>.ISequencerreplacesIScheduler.RxSchedulers.MainThreadScheduler, formerlyRxApp.MainThreadScheduler, is anISequencer.- Awaiting a command needs
using ReactiveUI.Primitives.Signals;, which provides the awaiter. Without it,await command.Execute()fails with CS1061. command.Execute().ToTask()from theReactiveUI.Primitivesnamespace also works and returns the last value. Both throwInvalidOperationExceptionwhen the command completes without a value, which aCreateFromObservablecommand can do;CreateandCreateFromTaskcommands always produce one.
Both await styles resume on the model context when they start in a turn. For
System.Reactive, use Runic.Application.Views.ReactiveUI.Reactive.
ReactiveUI 26
This package targets ReactiveUI 26.0.1, Binding 9.1.0, Primitives 9.0.0, and SourceGenerators 4.2.0. ReactiveUI 26 and Binding 9 keep the 25/8.x public API; they are majors because their .NET 11 assemblies no longer use runtime-async, so they also work on Mono. Runic's adapter API and generated contracts are unchanged; rebuild the app against the 26 packages.
Primitives 9 changes SubscribeSafe: a single lambda is now the onNext
handler. Rename error-only calls, such as source.SubscribeSafe(ex => Log(ex)),
to SubscribeSafeErrors. Binding 9 also makes BindCommandUnsafe and
BindInteractionUnsafe dispose the binding they replace when the control chain
changes.
When moving from ReactiveUI 24, note that ReactiveUI 25 moved IViewFor<T> and
IViewLocator to ReactiveUI.Binding, so upgrade the Runic adapter, app, and
generated web client together. An adapter binary compiled against ReactiveUI
24 can fail at runtime against ReactiveUI 25 or later even if a dependency
override compiles.
ReactiveUI now includes shared source generators. Remove old explicit generator
pins or update them to 4.2.0 or later. The SDK centrally pins 4.2.0; the same
generator package supports the System.Reactive flavor, so there is no separate
ReactiveUI.Reactive.SourceGenerators package.
Create explicit mappings with the Binding locator:
var locator = new DefaultViewLocator();
locator.CreateMappingBuilder()
.Map<EditorViewModel>(() => new EditorView())
.Map<EditorViewModel>(() => new CompactEditorView(), "compact");
For global lookup, replace ReactiveUI.ViewLocator.Current with
ReactiveUI.Binding.ViewLocator.GetCurrent().
Read-only and settable properties
As with any ReactiveUI View, the property's setter decides what the web
frontend may set. A public setter gets a set<Property> client method; give
status a private setter so it stays read-only to every View:
[Reactive]
public partial string Title { get; set; } = ""; // client: setTitle
[Reactive]
public partial bool IsDirty { get; private set; } // read-only: no setIsDirty
A hand-written property with private set => this.RaiseAndSetIfChanged(ref _isDirty, value)
works the same way. See
read-only and settable state.
Generated ReactiveUI contracts
Public IReactiveCommand<TInput, TResult> properties are inspected through
their interface contract, so ReactiveCommand, a base type, and a combined
command use the same generated input/result bridge. RxVoid is a no-result
operation and can complete with zero observable values; a non-void result must
produce exactly one observable value unless the property opts into Last or
Stream with [RunicCommandResult(...)].
Generated TypeScript exposes operation start, request-ID recovery, status,
cancellation, retained results, and cursor reads for streams.
Use [RunicCommandInput(typeof(TInput))] on a plain ICommand property to
generate a typed argument. That command remains synchronous fire-and-snapshot
work and has no generated operation result or cancellation handle.
Public getter-only Interaction<TInput, TOutput> properties become typed
browser surfaces. The generated client uses this shape:
const unregister = view.interactions.confirmDiscard.handle(
async (request, { signal }) => confirmInFrontend(request, signal),
);
The disposer, replacement handler, view disposal, unmount, disconnect, the cancellation of the command that asked, and the end of a navigation guard that asked (see Leave confirmation in a web View) abort the handler signal as appropriate. Requests go only to a selected mounted endpoint through a pull route, and never to another window or browser connection. With no eligible browser handler, the adapter does not consume the interaction, preserving normal ReactiveUI .NET handler precedence and unhandled behavior.
The browser handler is a ReactiveUI handler registered when the View's bridge
attaches. ReactiveUI asks the latest handler first, so a .NET handler registered
after the View attached, such as a WPF View's in WhenActivated, answers before
it, and a fallback registered in the ViewModel's constructor answers only when
no browser handler is eligible.
Model-context scheduling
Call AddRunicReactiveModelContext() (namespace
Runic.Application.Views.ReactiveUI) when you register the ViewModels, and
create commands without a scheduler:
using Runic.Application.Views.ReactiveUI;
services.AddRunicReactiveModelContext();
public CounterViewModel() =>
IncrementCommand = ReactiveCommand.Create(() => { Count += Step; });
The extension installs a model-context main-thread scheduler as
RxSchedulers.MainThreadScheduler, the scheduler a ReactiveCommand uses when
none is passed. Work scheduled inside a turn of a RunicModelContext runs on
that context, so a bridged command delivers its results and IsExecuting on
the model context of the Window that runs it, ordered with Bridge replies and
state publication. CanExecute follows its source synchronously, in the turn
that changes the state it observes. Routing follows the running turn, so two
Windows never share notifications. Work scheduled outside a turn, for example
from a timer thread or after ConfigureAwait(false), runs on the scheduler the
extension replaced, ReactiveUI's task pool. Runic logs that as
ReactiveSchedulerOutsideModelTurn (Views event 1043, Warning), once per
ViewModel type found on the call stack, named in its Source property, and once
for work with no ViewModel there. Runic Desktop, CS-WebUI and
RunicWindowTestHost.Create pass the application's ILoggerFactory to the
scheduler when a window opens, so the warning reaches the application's logs;
before that, or without a factory, it goes to Trace. To fix it, observe the
background source on the context's scheduler, as shown below for ObserveOn.
An application that builds ReactiveUI with RxAppBuilder adds
WithRunicModelContext() (namespace ReactiveUI.Builder) to that builder, as
Avalonia, MAUI and WPF applications add WithAvalonia(), WithMaui() or
WithWpf(), and still calls AddRunicReactiveModelContext():
using ReactiveUI.Builder;
using Runic.Application.Views.ReactiveUI;
services.AddRunicReactiveModelContext();
RxAppBuilder.CreateReactiveUIBuilder()
.WithPlatformServices()
.WithRunicModelContext()
.BuildApp();
Without it, BuildApp() with WithPlatformServices() replaces the scheduler
with ReactiveUI's default outside a test runner. An application that does not
use RxAppBuilder needs only AddRunicReactiveModelContext().
The extension replaces only ReactiveUI's default schedulers. A scheduler that an
application or UI platform set, such as WPF's dispatcher or a test scheduler, is
kept, and InstallMainThreadScheduler() returns false. To opt out, set
RxSchedulers.MainThreadScheduler, or call WithMainThreadScheduler on
ReactiveUI's builder, before AddRunicReactiveModelContext(), or replace it
afterwards. When a Runic Desktop, CS-WebUI or test-host window opens while
another scheduler is in effect, Runic logs it once per scheduler type: as
ReactiveSchedulerKept (Views event 1090, Information) when it was set before
AddRunicReactiveModelContext(), and as ReactiveSchedulerReplaced (1091,
Warning) when it replaced Runic's afterwards. A UI dispatcher's scheduler, such
as WPF's, is not logged. Commands read the scheduler when they are created, so
register the model context before the ViewModels create their commands.
The scheduler only delivers the command's notifications. The task body runs in
the context because the Bridge starts it in a turn. Set ViewModel state directly
in a CreateFromTask body, also after await, as with any MVVM library. Use
InvokeAsync only for code that left the context, for example after
ConfigureAwait(false) or in Task.Run. A bridged execution carries its turn's
context to the command's notifications, so a CreateFromTask(async cancellationToken => ...) command, which ReactiveUI completes outside the turn,
and a body whose last await uses ConfigureAwait(false) still deliver on the
Window's context. That does not apply when a ViewModel calls
command.Execute().Subscribe(...) itself: such a command delivers in the turn
only if its body resumes in the turn and it does not take a cancellation token.
Otherwise its notifications run on the fallback scheduler. The
threading rule
has the details.
For an explicit scheduler, for example an ObserveOn in a DynamicData
pipeline, inject ISequencer or call RunicReactiveSchedulerProvider.For(context).
Both return the context's own sequencer, one per context, which the main-thread
scheduler also uses. The injected sequencer is resolved from the window's scope
before the ViewModel is constructed, so a source that replays when the
constructor subscribes is already observed on the right context; a scheduler
that looked up the ViewModel's context could not, because the host binds the
ViewModel only after constructing it. The extension registers, with TryAdd, the scoped
IRunicModelContext, the singleton IRunicReactiveSchedulerProvider and a
transient ISequencer for the resolved context, preserving custom application
registrations. Dispose a scope asynchronously to drain its default owned
context. Each queued item captures its own ExecutionContext, so a trusted
interaction scope follows its own deferred work without leaking to another
queued operation.
The ViewModels need no RunicModelContextRegistry.Bind. The CS-WebUI, Desktop
and WPF hosts give the window the scope's context and bind the root ViewModel
to it. The window binds each child ViewModel it presents, including one a
ViewModel creates later, for as long as it presents it, and a navigator binds
the region entries it owns. A ViewModel already bound to a different context
fails when presented, with an InvalidOperationException naming its type. When
you build a WindowContentSession or RunicWindowTestHost yourself, pass the
context the ViewModels run on as its ModelContext; otherwise the window
creates a context of its own and commands and replies are not ordered with each
other.
AddRunicReactiveModelContext() moved from Runic.Navigation.ReactiveUI to this
package in W250, with IRunicReactiveSchedulerProvider and
RunicReactiveSchedulerProvider. Replace using Runic.Navigation.ReactiveUI;
with using Runic.Application.Views.ReactiveUI;.
The ReactiveUI reference guide defines the supported data shapes, operation semantics, interaction targeting, and model-context ownership.
Declared failures and ThrownExceptions
A ReactiveCommand reports every exception on ThrownExceptions, including a
declared RunicFailureException ([RunicFailure]) that the Bridge already sent
to the client as domain-failed. Without a subscriber, ReactiveUI routes it to
RxState.DefaultExceptionHandler, which breaks into the debugger and throws
UnhandledErrorException.
While a generated Bridge presents a ViewModel, it subscribes to the
ThrownExceptions of each bridged ReactiveCommand, so the ViewModel needs no
subscription of its own. Each exception is reported exactly once:
| Exception | Reported by |
|---|---|
Declared failure (RunicFailureException) or cancellation |
The reply to the client (domain-failed, cancelled); not logged as an error |
| Unexpected exception of a Bridge call | The Views runtime: the reply and event 1000 (awaited call) or 1004 (operation) |
Unexpected exception of an execution no Bridge call received, such as command.Execute().Subscribe() in .NET or a failing canExecute |
The Bridge: ReactiveCommandFailed (event 1042, Error), in category Runic.Application.Views through the Window's ILoggerFactory, with Model.Command as Source |
Two Bridges that present one ViewModel log an exception once. The Bridge
unsubscribes when it detaches; a command that no Bridge presents, such as one
on a retained page or in a WPF View, keeps ReactiveUI's behaviour. Subscribe to
its ThrownExceptions, or set ReactiveUI's handler with the builder's
WithExceptionHandler, for those.
To handle a command's exceptions yourself, use ObserveBridgeExceptions or
your own ThrownExceptions subscription:
DiscardCommand.ObserveBridgeExceptions(error => status.Report(error));
While ObserveBridgeExceptions observes a command, the Bridge does not log
that command's exceptions: the handler replaces the logging. It ignores
RunicFailureException and OperationCanceledException, also one that is not
the Bridge's, such as an HttpClient timeout when the command runs outside the
Bridge, and reports every other exception: the ILogger overload logs it as
event 1042 with the command's expression, such as SaveCommand, as Source
(pass sourceName to choose another), and the callback overload passes it on.
An unexpected exception of a Bridge call is still logged by the Views runtime
(event 1000 or 1004) as well. An exception thrown by the callback surfaces from
ThrownExceptions.OnNext, inside ReactiveUI. A raw ThrownExceptions
subscription does not stop the Bridge's logging. Dispose the returned
subscription only when the command outlives the handler's owner. The helper
works on any IHandleObservableErrors, such as a ReactiveObject.
Command disposal
ReactiveUI does not require disposing a command: Dispose only ends its
canExecute subscription. Follow ReactiveUI's rule for subscriptions and
dispose a command only when its canExecute observes an object that outlives
the command's owner, such as a service, another page or a parent's region.
- A command without
canExecute, or whosecanExecuteobserves the ViewModel's own properties (this.WhenAnyValue(...)), needs no disposal. - The entry commands (
CreateCompleteCommand,CreateDismissCommand,CreateCloseCommand) are disposed by their entry when it retires. CreateBackCommand()on a region of the same ViewModel, such as a shell'sMain, needs no disposal. Dispose it when a page creates it on its parent's region.- A handler registered on the ViewModel's own
Interactionneeds no disposal. Put View-scoped work inWhenActivated.
Navigation
Start with the ReactiveUI navigation walkthrough: pages, a leave guard, results and a guarded window close, with code from the Reactive Notes example. This section covers what is specific to Runic Views.
The observables and back command for RunicNavigator regions
(WhenCurrentChanged, WhenEntryChanged and CreateBackCommand) are in the
Runic.Navigation.ReactiveUI namespace of the
Runic.Navigation.ReactiveUI
package. It depends on Runic.Navigation and ReactiveUI only. This package
no longer references it, so an app without navigation does not carry it; add a
PackageReference to Runic.Navigation.ReactiveUI to navigate.
Use a NavigationRegion<TContent> for new navigation, and keep
ReactiveRoutedRegion<T> for existing RoutingState code. Expose the region as
a get-only property and the generator presents its Current like any content
slot.
CreateBackCommand() needs no scheduler: with AddRunicReactiveModelContext()
its notifications arrive on the Window's model context. Its output is a
NavigationOutcome, so a View can bind it. The Bridge logs its unexpected
exceptions, and on the shell's own region it needs no disposal (see
command disposal):
using Runic.Navigation.ReactiveUI;
BackCommand = Main.CreateBackCommand();
ReactiveUI activation follows a mounted View, not a navigation entry's lifetime:
put per-presentation subscriptions in WhenActivated, and per-entry ones in the
adapter's entry.WhenNavigatedTo(...).
The adapter's entry commands (CreateCompleteCommand, CreateDismissCommand,
CreateCloseCommand) also output a NavigationOutcome, so a View binds them
without RUNICBRIDGE003, and their entry disposes them when it retires; see
entry lifecycle and result commands.
Leave confirmation in a web View
ReactiveLeaveConfirmation.WithInteraction from the navigation adapter asks
through a ViewModel Interaction, which the generator bridges like any other
interaction. The ViewModel takes its entry context in the constructor:
public sealed record DiscardPrompt(string ItemName);
public sealed class EditViewModel : ReactiveObject, INavigationDepartureGuard, IDisposable
{
private readonly LeaveConfirmation _leave;
public EditViewModel(NavigationEntryContext entry)
{
_leave = ReactiveLeaveConfirmation.WithInteraction(
ConfirmDiscard, () => new DiscardPrompt(Name), () => IsDirty, discard: () => Name = _savedName);
SaveCommand = entry.CreateCompleteCommand(_ => Task.FromResult(_savedName = Name));
CloseCommand = entry.CreateCloseCommand(); // asks when dirty
}
public Interaction<DiscardPrompt, bool> ConfirmDiscard { get; } = new();
public ReactiveCommand<RxVoid, NavigationOutcome> SaveCommand { get; }
public ReactiveCommand<RxVoid, NavigationOutcome> CloseCommand { get; }
public ValueTask<bool> CanDepartAsync(NavigationDeparture departure, CancellationToken ct) =>
_leave.CanDepartAsync(departure, ct);
// Name, IsDirty, Dispose ...
}
public sealed partial class EditView : RunicView<EditViewModel>;
The web View registers the handler while it is mounted and runs the commands:
const unregister = view.interactions.confirmDiscard.handle(
async (prompt, { signal }) => confirmDialog(`Discard the changes to ${prompt.itemName}?`, signal),
);
const close = await view.startClose();
const settled = await close.outcome();
if (settled.ok && settled.value.kind === "Rejected") showStillEditing(); // the user said no
The browser is asked when the page's own window starts the departure:
| Departure | Who answers |
|---|---|
A bridged command of this View, such as close() or startClose() |
This View, mounted by the browser connection that ran the command |
| A bridged command of another View in the same window, such as the shell's Back | This View, mounted by the browser connection that ran the command |
A guarded window close: the close button with GuardClose, or RequestCloseAsync() |
This View, when it is the only presentation in the window with a mounted handler |
| Navigation that .NET code starts outside a command, such as a timer or startup | .NET handlers only |
| A command of another window that navigates this window's region | .NET handlers only |
- An interaction never crosses windows or browser connections. A command's question goes only to the connection and client that ran the command. A close has no browser caller, so it asks only when exactly one presentation in the window, from any connection, has mounted a handler; with several, as in a multi-client CS-WebUI window, none is asked.
- A .NET handler keeps ReactiveUI's order: the latest registered is asked first. A fallback registered in the ViewModel's constructor answers only when no browser handler is eligible, for example when the View isn't mounted or the departure came from .NET code. The web View registers its handler when it mounts, so a departure during the page's first render, such as a close just as the page opened, is answered by that fallback too.
- When the departure is superseded (another navigation of the region wins),
cancelled or its navigator closes, the request ends and
signalaborts. So does the close decision when the window closes without asking. Close the dialog when it does; a late answer is ignored. - A View that unmounts or disconnects while it asks fails the departure closed: the page stays, and a close keeps the window open.
- With no handler that answers, the departure fails closed: the command's
outcome is
Failed, a close keeps the window open, and the navigator logs event 1060 with theUnhandledInteractionExceptiontype.
System.Reactive flavor
For DynamicData changesets, place BatchBridgeSnapshots(model) after
ObserveOn(modelSequencer) and before Bind or SortAndBind. The downstream
delivery owns the batch even when scheduling is deferred. Annotate a read-only
DTO collection with [RunicCollection(nameof(Row.Id))] to publish indexed
updates. See the DynamicData guide.
Applications using ReactiveUI.Reactive, ReactiveUI.Binding.Reactive,
System.Reactive.Unit, or IScheduler should instead reference
Runic.Application.Views.ReactiveUI.Reactive.
The two packages expose distinct ReactiveUI namespaces and must not be mixed in
one application. If a generic interface command does not reveal its flavor to
the compiled-model generator, set
RunicApplicationFrontendReactiveUiFlavor=reactive in the project that generates the
bridge.
See the upstream Binding migration guide for ReactiveUI's application-level migration details.
| Product | Versions 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. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- ReactiveUI (>= 26.0.1)
- ReactiveUI.Binding (>= 9.1.0)
- ReactiveUI.SourceGenerators (>= 4.2.0)
- Runic.Application.Views (= 0.8.0-preview.5)
- Splat (>= 21.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.8.0-preview.5 | 0 | 10/17/2026 |
| 0.8.0-preview.4 | 806 | 10/17/2026 |
| 0.8.0-preview.3 | 795 | 10/15/2026 |
| 0.8.0-preview.2 | 107 | 10/15/2026 |
| 0.8.0-preview.1 | 167 | 10/15/2026 |
| 0.7.0-preview.9 | 483 | 10/14/2026 |
| 0.7.0-preview.8 | 95 | 10/12/2026 |
| 0.7.0-preview.7 | 47 | 10/12/2026 |