Migrating to typed layout lengths
Applies to: consumers on 0.11.0 or earlier, upgrading to the first release that contains
this change.
Package: BlazorNative.Components.
Kind of change: source-breaking, compile-time only. Nothing about the wire, the shells or the
ABI changed, so a mixed-version runtime is not a concern — only your markup and your C#.
Plain numbers still work. Width="200", Padding="16", Height="12.5" all compile exactly
as before — in this repository's own migration, 44 of the call sites needed no edit at all.
Only two spellings change: percentages become @BnLength.Percent(50), and auto becomes
@BnAutoLength.Auto. Everything else the compiler finds for you.
1. What changed
Every layout length on the component surface is now a struct type instead of string? /
float?:
BnLength?— a number of points, or a percentage.BnAutoLength?— the same, plus theautocase. It composesBnLength, so the grammar is written once.
Both live in BlazorNative.Components and both format invariantly via ToStyleValue(), so a
comma-decimal culture can never put "1,5" on the wire.
There is deliberately no conversion from string. That absence is the feature: it is what
makes Width="12px" a compile error instead of a value both native shells log and ignore.
The properties, and which type each got
| Type | Properties |
|---|---|
BnAutoLength? | Width, Height, Margin, Basis (on BnLayoutItem); BnModal.ContentWidth, BnModal.ContentHeight |
BnLength? | MinWidth, MaxWidth, MinHeight, MaxHeight, Top, Right, Bottom, Left (on BnLayoutItem); Gap, Padding (on BnLayoutContainer); BnList<TItem>.Width; BnModal.Padding |
Because 13.0 moved the layout surface onto the shared BnLayoutItem / BnLayoutContainer bases,
these two rows cover every layout component in one stroke: BnView and BnFlexPreset (via
BnLayoutContainer), and BnText, BnImage, BnScroll, BnButton, BnInput,
BnActivityIndicator, BnCheckbox, BnPicker, BnSlider, BnSwitch (via BnLayoutItem).
BnList<TItem> and BnModal are the two allowlisted non-derivers and are handled explicitly in
§8 and §9.
Note the split is intentional: Margin accepts auto and Padding does not, because that is
what the shells actually enforce. One shared grammar, with the real legality difference kept
rather than flattened.
The two nulls on BnAutoLength?
- The outer null (the
?) means unset — no attribute on the wire, so the shell resets that style. - The inner null (
BnAutoLength.Length) meansauto.
They are not the same value: resetting a removed Margin to auto would move the node rather
than restore it. This is also why every parameter is nullable and never bare —
default(BnAutoLength) reads as auto, and default(BnLength) is a real zero-point length.
2. What still compiles, untouched
Plain numeric literals in markup need no edit at all. This is the headline: most markup is unaffected.
<BnView Height="200" Width="12.5" Padding="16" Gap="8">
Integer literals convert through int → float → BnLength; decimal literals compile as
double and are carried by a dedicated double operator that exists for exactly that reason.
Across this repository, 44 call sites were plain numeric literals and every one of them compiled unchanged.
3. What must change
| Before | After |
|---|---|
Width="50%" | Width="@BnLength.Percent(50)" |
Basis="50%" | Basis="@BnAutoLength.Percent(50)" (or @BnLength.Percent(50) — it widens implicitly) |
Height="auto" | Height="@BnAutoLength.Auto" |
Margin="auto" | Margin="@BnAutoLength.Auto" |
string _w = "200"; … Width="@_w" | BnAutoLength? _w = 200f; … Width="@_w" |
float _p = 16f; … Padding="@_p" | still compiles — float converts implicitly |
Any string variable or property bound to a length must be retyped. A string no longer
converts, so Width="@someString" is now a compile error wherever someString is a string.
This is the bulk of a real migration: in this repository 76 @-bound sites changed their
expression type. The change is mechanical, and the compiler finds every one of them.
4. Reading the compiler's errors
The errors are correct but cryptic — none of them mentions lengths. Width="12px" reports
"syntax error, ',' expected". Keep this table:
| Markup | Error |
|---|---|
Width="12px" | CS1003 |
Width="50%" | CS1525 |
Width="auto" | CS0103 |
Width="abc" | CS0103 |
Width="100 px" | CS1003 |
The reason they read this way: when a component parameter is not a string, Razor compiles
the attribute's literal text as a C# expression. So 12px is not "a length that failed to
convert" — it never gets that far; it is 12 followed by px, and the parser asks for the comma
it expected between two things. Likewise 50% is a binary % with a missing right operand
(CS1525), and auto / abc are undefined identifiers (CS0103).
The table above was produced by compiling a probe project during design, not by reasoning about what the compiler ought to say. A diagnostic-quality analyzer would improve these messages; it was explicitly left out of this phase, and this table is what stands in for it.
5. The trap: boxed values through ParameterView / Dictionary<string, object?>
Read this if you build render trees by hand or write component tests. It is not obvious, and
it bit this repository in nine files during the migration: BnComponentTests,
BnFormControlTests, BnModalTests, BnTestHostTests, ForwardedParameterNameTests,
ScrollCommandTests, LayoutSurfaceSequenceBandTests, BnCameraDemo.cs and SmokeRoot.cs —
note the last two are not tests, so this is not a test-only hazard.
The conversions from float/double to BnLength/BnAutoLength are implicit operators —
compile-time only. They do not exist at runtime. So when a value is boxed into an
object? — which is what ParameterView.FromDictionary, Dictionary<string, object?> and
RenderTreeBuilder.AddComponentParameter(int, string, object) all do — the compiler never gets a
chance to apply the conversion, and Blazor's parameter setter does a cast, not a convert.
The result: it compiles fine and throws InvalidCastException at first render.
// ✗ compiles, throws at render — a boxed string
var p = new Dictionary<string, object?> { ["Width"] = "200" };
// ✗ compiles, throws at render — a boxed bare float, no implicit operator at runtime
var p = new Dictionary<string, object?> { ["Width"] = 200f };
// ✓ box the constructed type
var p = new Dictionary<string, object?> { ["Width"] = (BnAutoLength)200f };
var q = new Dictionary<string, object?> { ["Padding"] = (BnLength)16f };
var r = new Dictionary<string, object?> { ["Basis"] = (BnAutoLength)BnLength.Percent(50) };
var s = new Dictionary<string, object?> { ["Height"] = BnAutoLength.Auto };
Note the last two: a boxed "50%" or "auto" needs a semantic translation, not just a
cast — there is no mechanical rewrite for those two spellings.
The same applies to hand-written BuildRenderTree:
// ✗ b.AddComponentParameter(31, nameof(BnImage.Width), 120f);
b.AddComponentParameter(31, nameof(BnImage.Width), (BnAutoLength)120f);
Rule of thumb: wherever the value passes through object, write the cast yourself. Ordinary
.razor markup and strongly-typed property assignment are unaffected — the compiler handles
those.
6. What did not change
- The wire grammar.
width="50%",height="auto",padding="16"— byte-for-byte the same strings, produced byToStyleValue(). - Both native shells. No
.kt, no.swift, no.mmand nowire-vocabulary.jsonis in the branch diff, and no shell frame table was edited. If the phase had found itself editing Kotlin or Swift, the design would have been wrong. - The C ABI. Unchanged, still the frozen 80-byte / 10-export contract.
Grow,Shrink— stillfloat?. They are ratios, not lengths.BackgroundColor— stillstring?. Colour typing is a separate concern and is not in this phase.BnText.FontSize— stillfloat?.
7. Known limitation, stated plainly
Width="-8" still compiles. BnLength can represent a negative value.
Negative-legality is a per-property rule — negatives are legal on margin and on the insets
(Top/Right/Bottom/Left) and illegal everywhere else — and expressing that in the type
system would mean a distinct type per property group, or an analyzer. Neither was worth the
surface it would add. Negatives therefore remain enforced by the shells at runtime, exactly
as they were before this phase.
Negatives are not the only residual case. float.NaN and float.PositiveInfinity are in
exactly the same class: Width="@float.NaN" compiles, and ToStyleValue() puts "NaN" (or
"Infinity") on the wire for the shells to log and ignore, just as before. BnLength wraps a
float and does not narrow its domain, so any float a caller can produce — including the ones
arithmetic produces by accident, such as a 0f / 0f in a computed layout — reaches the wire and
is rejected there.
This is a deliberate decision, not an oversight. The phase's claim is narrower and true as
stated: unit and keyword errors (12px, 50% as a literal, auto, abc) are now compile
errors. Domain errors — negatives, NaN, infinities — are not.
8. BnList<TItem> — Height and ItemHeight stay float
BnList<TItem>.Height and BnList<TItem>.ItemHeight are unchanged, non-nullable float,
and this too was decided rather than skipped.
They are not styling inputs; they are the virtualization window's arithmetic — they are fed to
BnListWindow.Compute(offset, Height, ItemHeight, count, overscan), where ItemHeight is a
divisor. They must be point values. BnAutoLength cannot promise one: auto and 50% are
legal in the type and unusable in that arithmetic, and nothing surfaces viewport size back to
.NET, so there is no fallback measurement to recover a real number from.
BnList<TItem>.Width is typed (BnLength?), because it is a genuine layout length.
Consequence for consumers: BnList still does not derive from BnLayoutItem, so it does not
carry the full item surface. That is pinned in the phase's allowlist with this reason written
down.
9. BnModal — also typed
BnModal.ContentWidth, BnModal.ContentHeight (BnAutoLength?) and BnModal.Padding
(BnLength?) are typed too. They sit on the modal's content box rather than on the modal node
itself, which is why they carry their own names — but they are ordinary layout lengths, and a
consumer setting them from a string gets the same compile errors as above.
10. Migration checklist
- Build. Every affected site is a compile error — there is no silent behaviour change to hunt for.
- For each CS1003 / CS1525 / CS0103 on a layout attribute, apply §3.
- Retype any
stringfield/property that feeds a length toBnLength?orBnAutoLength?. - Grep your test and
BuildRenderTreecode for layout parameter names passed throughobject?— those compile today and throw at render. See §5. - Percentages and
autoare the only two spellings that need new syntax. Plain numbers do not.