Skip to main content

API stability

What you may depend on, what you may not, and how you will be told when it changes.

BlazorNative is pre-1.0. That does not mean "anything can happen" — the public surface of every shipped package is now recorded member-by-member and gated in CI. This page tells you exactly how far that goes.

The one-sentence version

The public API is marked but not frozen: a public-API change can no longer land unacknowledged, but a minor version may still break the surface deliberately, with a changelog line. At 1.0 the same files become a freeze.


What "marked but not frozen" means for you​

Every shipped library package except the analyzers carries a PublicAPI.Shipped.txt baseline — one line per public member, including enum members and nullability annotations. The Roslyn public-API analyzers turn three diagnostics into build errors in the required CI lane:

DiagnosticFires whenReads as
RS0016A public member exists that no baseline declaresYou added public API without saying so.
RS0017A baseline lists a member the assembly no longer hasYou removed or changed public API.
RS0037A nullability annotation differs from the baselinestring became string? (or the reverse).

A rename fires both RS0017 and RS0016 — the old name vanished, the new one was never declared.

The practical consequence, in three lines​

  • Before: a parameter could be renamed and nothing in the pull request would say so. You found out at dotnet restore.
  • Now: that pull request fails to build until its author writes the change into PublicAPI.Unshipped.txt — so the break appears in the diff as reviewable lines, and the changelog can carry it.
  • Still true while we are on 0.x: breaking is allowed. A feat: commit produces a new minor version, and that minor may break the surface. The gate makes a break a decision, not an accident. It does not make it impossible.

At 1.0 the files do not change — the rule about who may edit them does. See Compatibility statement.


The three tiers​

Every tiered public type sits in exactly one tier — a small number, shipped after the tier table was written, have no tier yet; see below. The tiers are a property of the type, not of the package it happens to live in.

TierWhat it means for your code
STABLEThe surface we intend to freeze at 1.0. Build on it.
PROVISIONALUsable, baselined, not advertised as stable. We will tell you when it moves; we are not promising it will not.
NOT-APIPublic for mechanical reasons. Binding to it is doing something the framework does not support.

The type-by-type record, with the reason for every placement, is the tier table in the repository: docs/plans/2026-07-21-phase-11.3-api-tiers.md. The full list of public types is the baselines themselves, src/*/PublicAPI.*.txt.

Why the largest non-stable bucket is the healthiest possible finding

NOT-API is the largest non-stable tier — but not one of its types is "unfinished". They are public because a C-ABI export mechanism, a cross-assembly composition root, or a source generator required them to be. That is a very different problem from half-built types, and it is why the honest mark for them is [EditorBrowsable(Never)] rather than [Experimental].

STABLE — build on this​

The surface an app author actually types.

PackageWhat is stable
BlazorNative.ComponentsThe components and their [Parameter]s — BnView, BnText, BnRow, BnColumn, BnButton, BnInput, BnImage, BnScroll, BnActivityIndicator, BnList<TItem>, BnModal, BnPicker, BnSlider, BnSwitch, BnCheckbox, the BnFlexPreset base, BnTheme, and the Flex* / ImageContentMode enums and constant holders.
BlazorNative.DeviceThe [Inject]-able façades — IGeolocation, INotifications, IBiometrics, ISecureStorage, ICamera — plus AddBlazorNativeDevice(). Every implementation is internal sealed, so an interface is the only thing you can bind to. This is the cleanest surface in the repo.
BlazorNative.CoreThe capability result/status types (GeolocationResult, GeolocationPosition, GeolocationStatus, NotificationSpec, NotificationStatus, BiometricStatus, SecureStorageStatus, SecretResult, CameraStatus, CaptureOptions, PhotoResult), IMobileBridge, INavigationManager, PlatformInfo / PlatformKind, NativeEvent, BridgeHttpRequest / BridgeHttpResponse, BnImageErrorEventArgs, BnScrollEventArgs.
BlazorNative.HttpBridgeHttpHandler and AddBlazorNativeHttp / the two AddBlazorNativeHttpClient overloads.
BlazorNative.RuntimeBlazorNativeApp (DefaultRoute, RegisterPages, ConfigureServices) and BlazorNativePage (Routed<T>, Named<T>). Your whole startup contract, deliberately tiny.

Some public types have no tier yet. Types that shipped after the tier table was written and have not been placed are listed in the tier table's own not-yet-tiered section. They are baselined like everything else, so a change to any of them still shows in the pull request's diff. Which tier each belongs to is an open decision — do not read a type's absence from the table above as a promise either way.

Example — every BlazorNative.* line here is STABLE (Home/Camera are this page's own stand-ins for your page components, not part of the framework):

using BlazorNative.Runtime;

// Home and Camera stand for your own page components.
internal sealed class Home : Microsoft.AspNetCore.Components.ComponentBase { }
internal sealed class Camera : Microsoft.AspNetCore.Components.ComponentBase { }

public static class AppPages
{
public static readonly BlazorNativePage[] All =
[
BlazorNativePage.Routed<Home>("/", "home"),
BlazorNativePage.Routed<Camera>("/camera", "camera"),
];
}
@* In a component: *@
@inject ICamera Camera

@code {
private async Task LoadPhotoAsync()
{
PhotoResult photo = await Camera.CapturePhotoAsync(new CaptureOptions(Quality: 80));
if (photo.Status == CameraStatus.Denied) { /* denial is DATA, never an exception */ }
}
}

PROVISIONAL — usable, movable​

TypeWhy it is not STABLE
DevHostBridge (BlazorNative.Core)The in-process mock bridge. Genuinely useful for your tests — it is the bridge double you register when a page mounted by BlazorNative.Testing injects IMobileBridge — but its members are mock-shaped: seeding hooks and canned results, not a contract designed for strangers.
BnLog / BnLogLevel (BlazorNative.Core)The logging seam — see Logging. The level names and BnLogLevel's ordinals are not expected to move; Sink's deliberately narrow shape and the line format were chosen before their first real consumers, so they may.
BlazorNative.Testing — BnTestHost, BnTestTree, BnTestNode, BnShellThe consumer test harness — see Testing a page. Its shape may move while real consumer suites shake it out.
AddBlazorNativeRenderer (BlazorNative.Renderer)A composition-root call you need only if you are building a custom host. It is a one-line delegate to generated code, so its shape is not ours to promise.

Use them. Pin your version if you depend on their exact shape.

NOT-API — public, but not for you​

These are public because a mechanism required it, not because they are a contract:

  • NativeRenderer and the renderer's in-memory patch model (RenderPatch and its derivatives, RenderFrame, NativeUiEvent, BlazorVersionMismatchException) — NativeRenderer is public so that an internal type in a different assembly can drive it. There is no scenario in which an app author sets StrictErrors.
  • The C-ABI interop surface in BlazorNative.Runtime — Exports (the [UnmanagedCallersOnly] entry points), BlazorNativeBridgeCallbacks, BlazorNativeFetchRequest/Response, BlazorNativeInitOptions/Result, BlazorNativePatch, BlazorNativeFrame, BlazorNativePatchKind, BlazorNativeNodeType — plus NativeShellBridge and NativeNavigationManager. These are frozen far harder than a managed baseline can express (struct sizes pinned by Marshal.SizeOf, fixed field offsets, a symbol-count gate on every published binary) — but that freeze is a contract with the two native shells, not with you. Their managed shape is an artefact of it.
  • The three analyzers. You cannot reference them at all — the package ships as an analyzer asset. See the BN00xx roster.
warning
[EditorBrowsable(Never)] is a signpost, not a barrier

NOT-API types carry [EditorBrowsable(EditorBrowsableState.Never)] plus an xmldoc line naming the mechanical reason they are public. That hides them from IntelliSense and restricts nothing — new SetStylePatch(...) still compiles today. It is the honest ceiling of a non-breaking change. The closing fix (making them internal) is breaking, and is tracked as a 1.0 criterion rather than done silently. If you have bound to one of these types, you are outside the supported surface and a minor version may move it.

Three NOT-API types cannot be marked, and we would rather say so than let you infer that an unmarked type is supported: two generated dependency-injection registration classes (BlazorNativeRendererServicesServiceCollectionExtensions, BlazorNativeHttpServicesServiceCollectionExtensions) and Razor's _Imports. An attribute cannot be added to source we do not own.

The C-ABI extension policy — how a "frozen" ABI grows​

"Frozen" describes the export set and struct layouts the shells bind to today; it does not mean the ABI cannot grow. It was built to grow additively: clipboard + share, geolocation, notifications, biometrics + secure storage and camera all landed on it with two struct grows and one export grow between them — and the last four with no ABI change at all, riding op values on the HostCallBegin slot. The rules that made that safe were enforced in code but never written as policy — this is the policy. It binds the framework's own contributors (the shells are the only supported callers), and it is what lets the 0.x → 1.0 freeze be a freeze of layout, not of capability.

ChangeClassWhy
Append a trailing callback slot to BlazorNativeBridgeCallbacksAdditiveblazornative_register_bridge(structSize, …) copies min(structSize, sizeof) bytes and zero-fills the tail. An older shell leaves the new slot NULL — the capability-unsupported signal, surfaced as NotSupportedException, never dereferenced. A newer shell's extra tail is ignored. Same rule for blazornative_init(structSize, BlazorNativeInitOptions*).
Consume a new ordinal — a BlazorNativeNodeType id, a BlazorNativePatchKind id, a HostCallBegin op value, a NativeEvent name, a style / event / prop stringAdditiveRides an existing int32 field or const char*; no offset moves. A shell that predates the value logs and falls back ("?" node type, unknown op → error status) rather than crashing. This is how every capability after geolocation was added with zero ABI churn.
Use BlazorNativePatch.Reserved0 (offset 20, int)AdditiveAlignment padding that the encoder always zero-fills (FrameArena.AllocPatches clears the block), so 0 is guaranteed to mean absent to every shell shipped so far. Spend it once, for a value whose zero is a safe default.
Add a JSON key to a HostCallBegin args / host_call_complete payload / headers blobAdditiveBoth shells parse JSON by key; unknown keys are ignored.
Reorder, resize, or re-type an existing field; move an existing offsetBreakingThe three mirrors read by offset. Marshal.SizeOf/OffsetOf pins in BridgeProtocolNativeTests / PatchProtocolNativeTests and the Kotlin/Swift drift tests red on it — by design.
Grow BlazorNativeFetchRequest / BlazorNativeFetchResponse (32 bytes each) or BlazorNativeFrame (24)BreakingThese cross without a size argument (fetch_complete(requestId, response*), the frame callback), so there is no negotiation to absorb a new field. Growing them needs a new sized export or a v2 struct — see #285, streaming, for the first case that will want one.
Repurpose an existing ordinal (incl. the reserved-dormant AppendChild = 2), rename or re-sign an export, change a return-code meaningBreakingIds and exports are never reused; a symbol-count gate on every published binary and the export-resolve tests pin the export names.

At 1.0 the breaking row becomes a major-version conversation; the additive rows stay free, exactly as an added IMobileBridge member does. Where the enforcement lives, so the policy points at code rather than at itself:

  • size negotiation — NativeShellBridge.Register / Exports.NegotiateInitOptions (src/BlazorNative.Runtime), documented in docs/bridge-extension.md §(b);
  • layout pins — BridgeProtocolNativeTests.cs, PatchProtocolNativeTests.cs (tests/BlazorNative.Runtime.Tests), ShellBridgeTest.kt / NativeFrameAdapterTest.kt (Android), BnDriftTests.swift (iOS);
  • Reserved0 — src/BlazorNative.Runtime/PatchProtocolNative.cs, offset asserted in the same tests.

The BN00xx and BN1xxx id reservations​

The analyzers package's real contract is not its class names — it is the diagnostic ids you type into a NoWarn, an .editorconfig severity, or a #pragma:

BN0004 · BN0010 · BN0011 · BN0013 · BN0014 · BN0020 · BN0021

That roster is pinned by a test in both directions — adding an id without updating it, or removing one, is a build failure. Renaming the C# class an analyzer lives in breaks nothing; renaming BN0011 breaks your build. So the id set is what we treat as frozen, and the analyzers package is deliberately excluded from the .txt baselines. See Analyzer rules.

BN1xxx is reserved for [Experimental] diagnostic ids and is disjoint from BN0xxx, permanently — both land in the same NoWarn, so a stale suppression must never start silencing a live warning. Ids are never reused.

[Experimental] is currently used on nothing. It produces a compile error at every use site, so it is spent only for informed consent about a genuinely unproven surface — the first member of a capability shipped ahead of device proof, for instance. It is not a synonym for "internal": marking a NOT-API type experimental would claim "this may change" about something that has not moved since it was marked, while breaking the build of anyone referencing the package for unrelated reasons.


Interfaces are consume-only​

IMobileBridge and INavigationManager are consume-only contracts.

Adding a member to them is a non-breaking change by declaration. Implementing them outside BlazorNative is unsupported.

This asymmetry is deliberate and it favours you:

  • Adding an interface member is invisible to every caller and fatal to every implementer.
  • IMobileBridge grows by construction — once per capability. Every capability so far has added to it, and more are planned. A policy that made each addition a major bump would make the framework unable to grow.
  • The only two implementations are ours (NativeShellBridge, DevHostBridge).

If you need a test double, do not hand-implement IMobileBridge. Mock a BlazorNative.Device façade instead — they are a few members each and exist precisely for this — or use DevHostBridge.

The policy is written into the interfaces' own xmldoc, so it reaches you through IntelliSense.


Compatibility statement​

Between 0.x minor versions (today)​

TierWhat you may rely on
STABLEThe surface is recorded and gated. It may break in a minor, but only deliberately: the change is visible in the pull request's PublicAPI.Unshipped.txt diff and carries a changelog entry. Nothing breaks silently.
PROVISIONALSame visibility, weaker intent — expect movement. Pin your version if the exact shape matters.
NOT-APINothing. It may move in any release. Changes are still recorded in the baseline, so they are visible if you go looking, but they are not announced as breaks.
The BN00xx idsStable. Your NoWarn will keep working.
The C-ABINot your contract — it is the framework's contract with its own shells. Do not P/Invoke it. It grows only by the extension policy: appended slots, new ordinals, Reserved0; existing offsets and exports never move.

Adding a member to an enum (a new GeolocationStatus, say) is source-compatible and behaviourally breaking — it compiles, then falls through your exhaustive switch. We treat it as a minor-version change with a changelog line, never as a free edit. Write a default arm.

At 1.0 and after​

TierWhat you may rely on
STABLEFrozen. A breaking change requires a major version. A removal or signature change to a STABLE member is a 2.0 conversation, not a changelog line.
PROVISIONALPromoted to STABLE, or removed, before the 1.0 tag. The tier does not survive 1.0.
NOT-APIExpected to become internal at or after 1.0. Treat it as already gone.
Package movesA type moving to a new package ships a [TypeForwardedTo] from the old one, which makes the move non-breaking at the source level.

How a break will be signalled​

  1. The changelog. Releases are generated by release-please from conventional commits; a breaking change carries the ! / BREAKING CHANGE footer and is rendered as its own section.
  2. The version. 0.x: a minor bump. Post-1.0: a major bump. Never a patch.
  3. The baseline diff. PublicAPI.Shipped.txt in the release's own diff is the machine-readable list of exactly what moved — the honest answer to "what actually changed?" is a file, not prose.

The limit of all of this, stated plainly​

A baseline records a surface; it does not judge it. These files will freeze a badly-named parameter as faithfully as a good one. They guarantee that a change is visible — they guarantee nothing about whether the API was worth freezing. Reading the baselines line by line during this work turned up two real defects in the design of shipped types that no test had caught, and those are being fixed as defects rather than blessed by the file that recorded them.


Roadmap to 1.0​

1.0 is defined by an explicit, checkable list of blockers, covering the API baselines and marking, real-device proof on both platforms, logging discipline, a surfaced render error, and this documentation. Which are met and which are open is recorded in that list, not here — this page would be wrong the day one of them closed.

The full list, with evidence for every row, lives in the repository: docs/plans/2026-07-22-phase-11.3-one-point-oh-criteria.md.

iOS on a real device is a blocker, not a caveat. CI proves iOS on the simulator — a real ARM64 execution of the real NativeAOT static library through the real xcodebuild toolchain, with frame tables asserted identical to Android's — and builds the device slice on every pull request. Neither is an iPhone, so 1.0 will not ship on them: real-device verification is blocker P3 in that list, tracked on #17. Its acceptance criteria are the things a simulator cannot prove — camera capture from a real sensor, Face ID / Touch ID against the Secure Enclave, real-GPS geolocation, APNs and universal links, code-signing and provisioning, and thermal/background behaviour. Android is device-proven.