Skip to main content

The wire

Between your C# and the native widget on screen there is exactly one boundary: a C-ABI, carrying fixed-size typed structs. Understanding it is optional for writing pages and essential for understanding why this project behaves the way it does.

It is not a JSON protocol

It used to be, in the WebAssembly era, and that layer was deleted. Today the renderer produces an in-memory patch model and the runtime encodes it as a typed-struct frame: a header followed by an array of fixed-size patch records. Nothing on the frame path is serialized, parsed, or allocated per-frame.

The design goals are stated in the protocol source itself, and they explain most of the shape you will see:

  • Minimal payload — only diffs, never full tree snapshots.
  • Zero-alloc friendly — structs for hot-path types.

The patches

A render cycle yields a list of atomic commands the shell applies to its widget tree:

PatchWhat it says
CreateNodePatchCreate a native widget of a given type, under a parent, at a host child index
RemoveNodePatchRemove a node and its subtree
SetStylePatchApply one style property to a node
UpdatePropPatchSet or update a property (a null value removes it)
ReplaceTextPatchReplace a text node's content
ScrollToPatchScroll a scroll node to an offset, or to the end of its content

Three details in there are load-bearing and worth pulling out:

Creation carries its own placement. CreateNodePatch has an InsertIndex: -1 means append (the mount-walk common case), and ≥ 0 inserts at that position for mid-list keyed inserts. Zero is a valid index, which is precisely why -1 is encoded explicitly rather than using zero as a sentinel. There is no separate append patch — it was retired once creation carried placement, and moves are remove-plus-insert.

The index counts real views only. Blazor's sibling slots include component slots that own no view; the renderer translates its own sibling positions into host child indices, skipping them. A shell never has to know that a component existed.

One patch commands rather than describes, and it is applied differently. Every patch above says what the tree is — except ScrollToPatch, which asks a live view to do something. Two consequences follow. Because a command is not idempotent, repeating it has to be observable, so it rides the frame stream where its position relative to the content around it is meaningful. And because "the end of the content" is a Yoga result the shell holds, a shell must queue the command and honour it after that frame's layout — scrolling where it decodes the patch would target the previous content's size, which is the bug the design exists to avoid. The guarantee the renderer offers is therefore the frame, not the patch index: the command and the content it follows arrive together.

Retired wire ids stay reserved. When a patch kind is deleted, its number is not reused and the remaining kinds do not renumber. A wire whose ids shift under a shell that was compiled against the old ones fails in the least debuggable way possible, so it is not allowed to happen.

The exports

The runtime's export surface is a small, deliberately boring set of cdecl entry points — initialize, shut down, report a version, register the frame callback, mount a component, dispatch an event, register the host bridge, complete a fetch, deliver a host event.

This site does not list their count or their signatures, on purpose: they are declared in one place, and that place is checked by the build. Exports.cs is the truth, and the analyzers enforce the rules the boundary lives by — see BN0020 and BN0021, which exist because an exception escaping a [UnmanagedCallersOnly] frame does not throw into your caller; it aborts the process.

Events come back the same way

A tap on a real Button becomes a blazornative_dispatch_event call carrying a handler id and a JSON argument payload, which the renderer routes to your EventCallback; your handler runs, the tree re-renders, and the resulting patches ride the frame callback back out. The round trip is synchronous on the dispatch lane — by the time a sync-completing handler returns, the re-render has already been delivered to the shell.

The argument payload is JSON, and that is not an inconsistency with the rest of this page: event args are low-frequency and shaped by Blazor's own event model, while frames are the hot path. The hot path got the structs.

The bridge

Host capabilities — navigate, storage, fetch — are callbacks the shell registers into the runtime, not P/Invokes the runtime makes outward. That inversion is what lets one .NET binary serve two shells that share no code.

BlazorNative.Http rides that bridge: with it referenced, a plain HttpClient works inside your components on Android. On iOS, the reference shell's fetch is a deliberate stub — see Shells → iOS, which tells you exactly what to implement and why the stub fails loudly instead of hanging.

Fetch is one-shot: fully buffered, UTF-8 text, no streaming

The shell completes a fetch with a single call carrying the whole body as a UTF-8 string. There is no incremental delivery path on the ABI, so Server-Sent Events, chunked reads and long-polling all degrade to "wait for the complete response" (effectively polling), and binary bodies are unsupported. Streaming would be an ABI-shaped change — a roadmap item, not a handler option (#257).

Adding a capability to this contract touches the ABI in several places at once. The procedure is in the repository, beside the code it changes: docs/bridge-extension.md.