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:
| Patch | What it says |
|---|---|
CreateNodePatch | Create a native widget of a given type, under a parent, at a host child index |
RemoveNodePatch | Remove a node and its subtree |
SetStylePatch | Apply one style property to a node |
UpdatePropPatch | Set or update a property (a null value removes it) |
ReplaceTextPatch | Replace a text node's content |
ScrollToPatch | Scroll 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.
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.