ReactiveUI 26 support

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.