This document records the implemented architecture behind Runic's ReactiveUI 26 bridge. It is an internal contract design, not a second public wire specification. Application authors normally work with generated C# attachments and TypeScript clients; the permanent routes described here are transport details.
One generated type graph
The compiled-model generator creates a closed BridgeTypeGraph for every
exported data boundary. The same graph drives all of these outputs:
- direct C# JSON readers and writers;
- TypeScript types and decoders;
- structural equality for checked field writes;
- contract fingerprint inputs; and
- command and interaction input/output validation.
This keeps property state, command payloads, and interaction payloads aligned. There is no reflection-based JSON fallback at runtime. Discovery names the unsupported member path, which makes contracts reviewable before an application ships.
The graph accepts a deliberately finite set of CLR shapes: scalar primitives,
exact numeric and date/time forms, public DTOs, supported collections,
string-key dictionaries, explicit closed unions, and custom codecs. It rejects
cycles, arbitrary object values, non-string dictionary keys, flags enums
without a codec, and open polymorphism. A ViewModel remains a presentation
reference rather than becoming a recursive DTO.
Data subscriptions follow supported nested DTO members and collection items,
disposing removed/replaced subscriptions before publishing the next complete
snapshot. This is observation, not a deep browser patch protocol.
ObservableCollection changes provide their own collection events. Plain
List<T> and Dictionary<string, TValue> remain valid snapshot values, but
their in-place mutations need an owning PropertyChanged notification or a
replacement value to become visible.
The codec is canonical: 64-bit and arbitrary integers are decimal strings, decimals are exact strings, floats must be finite, and dictionaries are written in ordinal key order. This canonical value is also the input digest for idempotent operations, so JSON property order cannot turn a retry into a different request.
Command operations
Runic separates an ordinary bridge command call from a retained operation. The former keeps the existing fire-and-snapshot behavior. The latter is the generated recovery API for ReactiveUI commands and has a request ID:
const operation = await editor.startSaveWithRequestId(id, request);
const outcome = await operation.completion;
if (outcome.kind === 'succeeded') use(outcome.result);
BridgeOperationRequest binds that ID to the generated contract, command
member, and canonical input digest. The registry checks for a prior matching
request before availability, so a retry observes accepted work after
CanExecute has since changed. A reused ID with another member or input is an
identity conflict.
Operations retain bounded terminal data. Status includes success, failed,
cancelled, expired, or unknown; result delivery failures are separate from the
execution result. Waiters can stop waiting without cancelling work. Explicit
cancel() requests the work token, while a later successful completion remains
successful. Closing a session stops admission, asks its owned operations to
cancel, then drains them.
Generated handles attach their command member to every recovery, status, wait,
cancel, and stream request. A request ID is therefore not an accidental shared
namespace for unrelated commands. Scalar results and terminal stream replay
share a 256 KiB window budget. Completing a stream evicts older terminal
operations until its replay fits; a replay larger than the budget is cleared
and reports stream-retention-too-large. Running streams separately reserve
their declared maximum against a 256 KiB budget before execution; an admission
that cannot fit is rejected with stream-capacity. A default-size stream
therefore occupies that running budget until it completes. Handle completion
is lazy: the first read or wait() begins one cached terminal observation.
Result cardinality is explicit. RxVoid/Unit has no result; non-void
ReactiveUI commands require exactly one value by default. The
[RunicCommandResult] attribute opts into Last or bounded Stream. A stream
stores sequenced, cursor-readable values with a bounded replay buffer and
reports overflow. Combined ReactiveUI commands produce a collection as their
single command result unless their property is explicitly marked as a stream.
No-result commands use completion semantics and may publish zero internal
values without turning a successful effect into a cardinality failure.
[RunicCommandInput(typeof(T))] supplies the typed payload contract for a
plain synchronous ICommand. It generates only fire-and-snapshot execution;
there is no operation result, stream, or cancellation shape to infer from that
interface.
The permanent status, wait, cancel, and stream routes validate contract and
request identity. Generated clients turn an unobservable admission or status
into BridgeOperationUncertainError; callers recover the same request ID and
must not casually retry an effect.
Model execution and delivery
IRunicModelContext owns short serialized reads and mutations for a mutable
model graph. These types and RunicModelContextRegistry are in the
Runic.Navigation package and namespace. RunicModelContext provides the default
queue. A synchronous bridge route enters a synchronous turn only to decode,
inspect state, or change state. It never holds that turn across a task, I/O
operation, or interaction.
turn: capture input / change local state
await: storage, network, or interaction
turn: apply the result / take the next snapshot
RunicModelContextRegistry maps reference identities to that owner. A window
root receives an acquired default context. Composition roots can bind an
application-owned context to an application-shared root and its independently
presented children. A model cannot silently move to another context; conflicting
claims fail. Presentation lifetimes do not dispose a shared context.
Both model context and context-backed scheduler queues capture
ExecutionContext for each individual item rather than for a whole queue
drain. A trusted interaction invocation therefore follows its deferred work
without becoming ambient state for unrelated queued work; suppressed flow stays
suppressed.
Snapshots are captured in the model turn and then delivered through an ordered outbound queue. This avoids re-entering model mutation while a native transport is synchronously publishing JavaScript. Full snapshots can coalesce at the delivery boundary; operation terminals and interaction requests do not use the broadcast channel.
The Runic.Navigation.ReactiveUI adapters expose a context-backed scheduler
provider. The default flavor returns ReactiveUI.Primitives.Concurrency.ISequencer; the System.Reactive
flavor returns System.Reactive.Concurrency.IScheduler. Neither modifies a
process-wide main-thread scheduler. Native UI dispatch and browser rendering
remain owned by their hosts.
services.AddRunicReactiveModelContext() is the DI setup for either selected
adapter. It uses TryAdd for the scoped IRunicModelContext, singleton
scheduler provider, and selected flavor's transient scheduler registration,
preserving application overrides. The provider returns one scheduler per context
for as long as that context is alive; repeated resolutions share that instance.
The default context is drained by asynchronous scope disposal. Application
composition still binds each root and independently
presented child through RunicModelContextRegistry.
Interaction ownership and delivery
ReactiveUI interactions are recognized separately from state. A generated
BridgeInteractionDescriptor<TModel> attaches one RegisterHandler lease for
each Interaction object and shares it across all presentations of that model.
Each WindowContentSession owns only its route registration, mounted endpoint
leases, pending requests, and receipts.
When a command or operation calls Interaction.Handle, the bridge enters a
trusted RunicInteractionInvocation scope. The adapter uses the scope's
session, route, client, and authenticated connection identity to choose an
eligible browser endpoint. It never chooses the most recently mounted view.
Without a current scope or active matching endpoint, the Runic handler returns
without output, so ReactiveUI continues with application-installed .NET
handlers or its usual unhandled behavior.
The selected browser endpoint waits on __runicInteractionWait; its own
authenticated request receives the prompt and it replies through
__runicInteractionReply. A reply is accepted only if its request ID,
contract, route, presentation, connection, handler generation, and output
codec match the pending request. Duplicate equal replies return the original
receipt; stale or conflicting replies cannot complete another request.
Capability registration is separate from the request pull. An active handler therefore stays eligible between polls, and each selected presentation has a bounded pending queue. A request that is no longer eligible for that presentation is cancelled on delivery; Runic does not reassign it to another window.
Unmounting, connection loss, scope cancellation, timeout, and session close
terminate a selected pending interaction. The generated TypeScript handler gets
an AbortSignal for the same lifecycle. A boolean rejection is ordinary
application output, while cancellation and browser-handler failure remain
distinct outcomes. Browser prompts are never published in a state broadcast.
Background code does not acquire a browser target by accident. It either uses
a .NET handler or deliberately supplies a RunicInteractionInvocation with a
chosen WindowContentSession and route. ReactiveUI's Handle(input) itself
does not take a cancellation token; bridge-scoped calls receive the operation
or command token through the invocation scope.
Presentation and adapter boundaries
The core package has no ReactiveUI dependency. It owns type codecs, model contexts, operation retention, session routing, and transport validation. The optional default and System.Reactive adapters own ReactiveUI command execution, interaction attachment, view location, activation, and scheduler adaptation.
Each ReactiveRunicView<T> or ReactiveRunicWindow<T> gets a separate mount
activation lease. The ViewModel's IActivatableViewModel.Activator remains
active until the final lease releases it. ReactiveRoutedRegion<T> maps
RoutingState.CurrentViewModel into generated content. This gives Runic the
same useful activation and routing boundary as a native integration while the
frontend retains responsibility for visual layout and DOM binding.
The two ReactiveUI flavors expose different namespaces and unit/scheduler
types. An application selects exactly one adapter. The generated model tool
uses the property contract to choose an adapter; when an interface-typed
contract is ambiguous, set RunicBridgeReactiveUiFlavor=reactive for the
System.Reactive flavor.
The exact generated codec/command path is covered by a ReactiveUI Native AOT fixture with no warnings. That verifies the bridge contract under AOT; it does not represent every native host or frontend combination.
The nearest source references are the type graph, operation runtime, interaction router, model context, and default adapter.