Runic supports ReactiveUI 26.0.1, Binding 9.1.0, Primitives 9.0.0,
and SourceGenerators 4.2.0. The default integration uses
ReactiveUI.Primitives; applications using the System.Reactive distribution select
Runic.Application.ReactiveUI.Reactive. Select one flavor for an application,
then rebuild the application and its generated clients together when moving
between ReactiveUI majors. ReactiveUI 26 keeps the ReactiveUI 25 public API; the
adapter migration notes
cover the Primitives 9 SubscribeSafe change and the namespace changes from
ReactiveUI 24. If an interface-typed generic command leaves the
flavor ambiguous, set RunicBridgeReactiveUiFlavor=reactive in the generating
project for the System.Reactive flavor.
ReactiveUI remains the .NET ViewModel API. Runic generates a typed bridge for selected public state, commands, interactions, and known view content. It does not turn arbitrary observables, methods, CLR object graphs, or ReactiveUI APIs into browser APIs.
Supported contract surface
| ReactiveUI feature | Generated bridge behavior |
|---|---|
ReactiveObject, [Reactive], OAPH / ToProperty |
Public model state is observed through INotifyPropertyChanged. The generated property is exported after ReactiveUI's source generator has run; observables themselves are not serialized. |
WhenAnyValue, operators, validation |
Use them normally in .NET. Export their resulting supported state. INotifyDataErrorInfo property errors are published; ReactiveUI.Validation alone is not an exported validation contract. |
IReactiveCommand<TInput, TResult> |
Discovers interface, base, concrete, and combined command shapes independently of the command factory. Generated input/result codecs determine whether its values are bridgeable. |
Plain ICommand |
Add [RunicCommandInput(typeof(TInput))] to generate a typed argument. It remains synchronous fire-and-snapshot work: no retained result, operation handle, or invented cancellation contract. |
CanExecute and IsExecuting |
No-input commands publish canX; typed ReactiveUI commands also publish isXExecuting. Parameterized availability is checked from the decoded input at admission and execution time. |
Interaction<TInput, TOutput> / IInteraction<TInput, TOutput> |
A getter-only interaction property becomes a typed browser handler surface. Requests use a selected mounted endpoint and preserve normal ReactiveUI .NET-handler/unhandled fallback when no browser handler is eligible. |
IViewFor<T>, locating views |
ReactiveRunicView<T> and ReactiveRunicWindow<T> implement IViewFor<T>. ReactiveRunicViewLocator adapts explicit ReactiveUI mappings and contracts. |
RoutingState |
ReactiveRoutedRegion<T> projects CurrentViewModel to observable generated content. Navigation remains .NET code; Runic does not generate URL history or an Avalonia routed host. |
WhenActivated |
Every mounted Runic presentation holds an activation lease. A shared ViewModel remains active until its final presentation unmounts. |
Bind, BindTo, BindCommand, converters |
These are still useful for .NET views. They do not bind DOM elements or frontend components; use the generated TypeScript client for that. |
| Schedulers | IRunicModelContext owns short serialized model turns. The default adapter supplies a context-backed ISequencer; the System.Reactive flavor supplies an IScheduler, without changing ReactiveUI's process-global schedulers. |
Data contracts
The compiled-model generator builds one closed type graph for state, checked writes, command input/results, and interaction input/results. It emits direct C# codecs plus TypeScript types and decoders, so normal bridge serialization does not need reflection metadata and remains suitable for trimming and Native AOT.
Supported values include strings, booleans; signed/unsigned 8-, 16-, 32-, and
64-bit integers; BigInteger; finite float/double; decimal; enums;
Guid; DateOnly; TimeOnly; DateTime; DateTimeOffset; TimeSpan;
nullable values; public DTO records/classes; arrays; standard read-only/list
interfaces; ImmutableArray, ImmutableList, ObservableCollection, and
ReadOnlyObservableCollection; and string-keyed dictionaries.
The wire format is intentionally exact:
| CLR value | TypeScript value and wire form |
|---|---|
64-bit integers and BigInteger |
bigint, encoded as an invariant decimal JSON string |
decimal |
string, encoded as an invariant exact decimal string |
| finite floats | number; NaN and infinities are rejected |
| enum | declared stable name string |
| GUID and date/time values | validated round-trip string; DateTime preserves its CLR kind and DateTimeOffset is normalized to UTC |
TimeSpan |
invariant c duration string |
| observable collection | snapshot array; collection changes and supported nested DTO/item notifications publish a new snapshot |
DTO members include inherited application members and stop before framework
base classes. A public constructor must be usable to reconstruct a decoded DTO.
[RunicIgnore] excludes a member, [RunicInclude] makes an intentional
inclusion explicit, and [RunicAlias("wireName")] provides a stable wire name.
Aliases may contain characters such as hyphens. Generated TypeScript quotes the
declaration and uses bracket access, for example state["exact-id"].
Hidden-member and wire-name collisions are diagnostics.
Use [RunicUnion(typeof(CaseA), typeof(CaseB))] on an interface or base class
for a closed polymorphic boundary. Each case may use
[RunicUnionCase("case-name")]; the wire value has a $case discriminator.
Flags enums require an explicit codec. Unsupported values, cycles, open-ended
polymorphism, non-string dictionary keys, and arbitrary object graphs are
rejected with a member path rather than falling back to reflection JSON.
For a type outside the built-in graph, implement static
IRunicBridgeCodec<T>.Read and .Write, then pair
[RunicBridgeCodec(typeof(MyCodec))] with a [RunicCodecShape] on the codec.
The shape gives the generated TypeScript type, validating decoder expression,
and optional encoder expression (identity is the default); a .NET-only JSON
converter cannot describe a safe frontend contract.
ObservableCollection and ReadOnlyObservableCollection report item changes
and refresh their generated snapshot. Plain List<T> and
Dictionary<string, TValue> are supported data shapes, but they have no
in-place mutation event. Raise PropertyChanged for their owning property or
replace the collection/dictionary after a mutation so the bridge can publish
the next snapshot.
Typed command operations
Existing ordinary command calls preserve their fire-and-snapshot behavior. Every discovered ReactiveUI command additionally has an idempotent operation API. Its input is decoded before the command is admitted, and the same request ID plus canonical input recovers the original work. Reusing an ID for a different command or input is rejected.
RxVoid/Unit commands have no result and complete successfully even when
their observable yields zero values. A non-void command defaults to an
exactly-one result. Mark a property when its observable intentionally has a
different cardinality:
[RunicCommandResult(BridgeCommandResultCardinality.Last)]
public IReactiveCommand<SaveRequest, SaveResult> SaveCommand { get; }
[RunicCommandResult(BridgeCommandResultCardinality.Stream)]
public IReactiveCommand<Query, Row> SearchCommand { get; }
The generated client exposes startSave(input),
startSaveWithRequestId(requestId, input), and
recoverSaveWithRequestId(requestId). Each returns an operation with
status(), completion, wait(), and cancel(). A stream operation also
has stream(cursor?), returning ordered { sequence, value } items and the
next cursor. completion starts its terminal wait only when it is read, and
wait() starts the same cached wait. Results, operation count, individual
stream replay, and aggregate window retention are bounded. A delivery error
such as result-too-large, result-encoding-failed, stream-overflow, or
stream-retention-too-large is
visible in the status; it does not make a completed effect safe to retry.
BridgeOperationUncertainError means admission/status could not be observed,
so recover that request ID instead of automatically starting the action again.
The generated handle supplies its command member on status, wait, cancellation, and stream reads. Reusing a request ID cannot let a handle for one command read or cancel another command's operation, even where the command shapes match.
Runic cancels the subscription for an individual command execution. Task work
must honor its cancellation token. Continue observing ThrownExceptions in
the application: the bridge's rejected/failed reply does not replace
ReactiveUI's command error policy.
Use a plain synchronous command where its fire-and-snapshot contract is the right fit:
[RunicCommandInput(typeof(SaveRequest))]
public ICommand SaveCommand { get; }
Browser interactions
An interaction property is application-owned and must be a public getter with no public setter. Its browser surface is typed from the same graph:
const stop = view.interactions.confirmDiscard.handle(
async (request, { signal }) => showConfirmation(request, signal),
);
// Call on component unmount when `view.dispose()` is not already its owner.
stop();
handle accepts a synchronous or asynchronous output and returns a disposer.
Replacing a handler, disposing it, or disposing the view aborts its
AbortSignal. Requests are delivered by an authenticated pull route to one
already-mounted handler; Runic never broadcasts interaction input in state.
The selected endpoint unmounting, disconnecting, timing out, or cancellation
ends that request. A user answer such as false is a normal output and differs
from cancellation or handler failure.
The adapter registers one handler for each Interaction object even if several
views or windows show the same model. It selects the browser only inside the
trusted bridge command/operation scope. Background .NET work has no implicit
"last mounted window" target: use a normal .NET handler or explicitly enter a
RunicInteractionInvocation scope with the intended session and route.
If a browser mounts multiple presentations of the same route, designate one presentation to register each interaction handler. Multiple eligible handlers on that connection make the destination ambiguous and preserve the normal .NET fallback. The Reactive Notes Svelte and Angular demos keep a mirrored editor mounted while the main editor owns confirmation.
Browser handler capabilities are registered independently of the request pull, so an active handler remains eligible across a short polling gap. Each mounted presentation has a bounded pending queue. A request that is no longer eligible for that presentation is cancelled on delivery; it is not moved to another window.
Execution contexts
The model context types are in Runic.Navigation; add
using Runic.Navigation; when upgrading from a preview before preview.4.
The navigation and scheduler helpers are in Runic.Navigation.ReactiveUI, or
Runic.Navigation.ReactiveUI.Reactive for the System.Reactive flavor. They can
be installed independently for native MVVM; the corresponding Application
adapters reference them transitively. See the
preview.4 migration tables.
RunicModelContext queues short synchronous reads and mutations. Await I/O or
an interaction outside a turn, then use a later turn to commit state:
await context.InvokeAsync(() => viewModel.IsSaving = true, cancellationToken);
var result = await repository.SaveAsync(request, cancellationToken);
await context.InvokeAsync(() => viewModel.Apply(result), cancellationToken);
WindowContentSession acquires a default context for its root model. The
CS-WebUI and Desktop hosts instead bind the root to the window scope's
IRunicModelContext when one is registered, so the context injected into the
ViewModel and the one used by its bridges are the same; a root already bound to a
different context fails window creation. Compose an app-shared graph
deliberately with RunicModelContextRegistry.Shared.Bind or .Acquire; bind
its root and independently presented children together. A second, different
context for the same object is rejected. Mounts lease views, not context
ownership. A custom IRunicModelContext must report IsExecuting for code
inside its turns: synchronous bridge routes run nested work inline in that case
and otherwise block until InvokeAsync completes. Snapshot delivery is ordered after its model turn so a
synchronous host callback cannot run while the model gate is held.
The default adapter's RunicReactiveSchedulerProvider.For(context) produces
an ISequencer; the System.Reactive flavor provides the analogous
IScheduler. They are delivery tools, not a replacement for the async command
body, host UI dispatcher, browser event loop, or application-wide ReactiveUI
main-thread scheduler.
Create and bind the context before constructing commands, then pass its scheduler as the positional scheduler argument to the ReactiveUI factory:
using Runic.Navigation;
using Runic.Navigation.ReactiveUI;
services.AddRunicReactiveModelContext();
public EditorViewModel(
EditorSession session,
IRunicModelContext modelContext,
ISequencer scheduler)
{
_scheduler = scheduler;
Workspace = new EditorWorkspaceViewModel(session, this, _scheduler);
_contextLease = RunicModelContextRegistry.Shared.Bind(modelContext, this, Workspace);
}
protected ReactiveCommand<string, RxVoid> CreateCommand(Func<string, Task> work) =>
ReactiveCommand.CreateFromTask<string>(work, _scheduler);
Bind every independently presented child, including dynamically routed document
models, to that same context; keep each lease for as long as it is presented.
AddRunicReactiveModelContext() uses TryAdd to register one scoped default
context, one singleton scheduler provider, and a transient scheduler registration.
The provider returns one scheduler per context for that context's lifetime, so
repeated resolutions return the same instance, including for a singleton context.
An application's custom registrations remain in control; a singleton ViewModel
must not resolve a scoped context through that scheduler. Dispose the DI scope
asynchronously to drain a default context it owns.
Command Execute completion is separate from scheduled IsExecuting and
CanExecute notifications. The context scheduler serializes those
bridge-visible notifications with model turns, so state publication and bridge
replies retain their ordering. A default headless scheduler can notify later;
that is a normal scheduling choice, not a global scheduler setting to change.
The model context and both scheduler adapters capture ExecutionContext per
queued item. This preserves a trusted command/operation interaction scope for
its own deferred work without leaking that ambient state into another queued
turn; ExecutionContext.SuppressFlow() remains respected.
Avalonia comparison
ReactiveUI.Avalonia 12.1.6 also targets ReactiveUI 26. It supplies Avalonia controls, property binding, visual-tree activation, routed hosts, and dispatcher integration. Runic's equivalents are logical .NET presentations, acknowledged browser mounts, explicit content maps, generated frontend contracts, and a host-neutral model context. It intentionally does not add Avalonia as a dependency or copy its control/template/converter APIs.
The implementation boundaries are the type graph, operation emitter, interaction emitter, bridge runtime, and ReactiveUI adapter.
The ReactiveUI-specific Native AOT fixture exercises the direct generated codecs and command bridge without warnings. It is focused contract coverage; it does not claim full host, platform, or frontend-matrix coverage.
The default and System.Reactive flavors also run the same behavioral conformance source. It covers command cardinality, cancellation and actual availability; scheduler FIFO/cancellation/ambient-context flow; scoped DI and custom-registration preservation; plus .NET fallback and typed browser interaction paths.
Upstream: ReactiveUI 26.0.0, 26.0.1, Binding 9.0.0, Binding 9.1.0, Primitives 9.0.0, ReactiveUI 25.0.0 and the Binding migration guide.