Make Blazor attribute binding conversion Native AOT and trimming compatible

Open
#70,403 2 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
12/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Active
Tech stack
csharp
Domain
compilers, web-dev

Research direction

Start by reading the existing BindConverter and EventCallback.Factory.CreateBinder implementations, then trace how the Razor compiler lowers @bind today and where the array path uses MakeGenericMethod. Compare each proposed lowering in the issue against the current generated code to find the gaps. Done means the supported conversion scenarios run under Native AOT and trimming with no ILC warnings and no reflection fallback, while existing formatting, culture, callback and validation behavior is unchanged. This is a design-heavy proposal spanning the Razor compiler and Blazor runtime, so it is not a starting point for a first contribution.

Written by the indexing model from the issue text.

Description

area-blazor NativeAOT

Summary

This feature enables Blazor attribute binding conversion to work with Native AOT and full trimming without runtime converter discovery or dynamically constructed conversion code. It preserves supported formatting, parsing, callbacks, input-local state, and conversion-related validation behavior.

Razor favors statically selected conversion whenever the required type and conversion are known. Framework conversion handles common types and enums; known custom converters are referenced directly. When generic code needs custom conversion, a fixed cascading value supplies the appropriate converter through a typed carrier. Applications that do not enable additional AOT features or checks retain their supported experience without new warnings or required changes.

Goals

  1. Support concrete and generic conversion without runtime discovery. Element bindings and input components handle supported scalar, enum, nullable, array, and custom-converter values without framework runtime converter discovery or reflected generic construction in strict execution. A generic component can originate a binding to its own state without receiving a parent-facing converter parameter.

  2. Preserve binding and input behavior. Supported culture, formatting, null/empty values, invalid input, synchronous/asynchronous setters, after-callbacks, input-local state, and EditContext notifications retain their existing semantics. Supplying a converter does not bypass the input's established editing and validation behavior.

  3. Provide a strict path without degrading ordinary upgrades. Supported conversion scenarios publish without trimming or ILC warnings attributable to these framework paths and execute without reflection fallbacks. Upgrading without opting into additional AOT features or checks adds no warnings or required AOT-related code, accessibility, or configuration changes.

Non-goals

Expanding the set of supported conversions. Framework conversion and statically supplied custom converters cover the existing conversion surface; adding conversions for previously unsupported types is not part of this feature.

Proposed solution

Extend BindConverter and the binding callback factory so known conversions can be selected directly and custom TypeConverter instances can be supplied explicitly. No new generic conversion interface is required. Existing Value and ValueChanged delivery remains responsible for ordinary component binding; forwarding a typed value alone does not require attribute conversion.

The selection model has two paths:

  1. Statically known conversion. Razor uses framework conversion for supported known types or directly references the converter associated with a known custom type. Reusable conversion logic is not generated anew for every binding.
  2. Generic resolution when needed. Framework conversion handles supported built-in and enum cases without custom discovery. A generic consumer that needs a custom converter receives it through a fixed cascading value, typed by the target value type.

Custom converter associations come from compiler-visible declarations such as TypeConverterAttribute. Library and application definitions make the required converters available ahead of time, including statically established generic usages. The selected application's definitions determine availability; parents do not manually pass a converter through every component layer.

BindConverter owns binding-specific formatting/parsing, including Boolean attribute behavior and supported array/nullable handling. EventCallback.Factory.CreateBinder continues to return EventCallback and retains assignment, empty-input, and synchronous/asynchronous callback behavior. Culture and format remain operation arguments, not mutable settings on a shared carrier.

The following APIs and lowering examples are proposals, not implemented behavior or API-approved signatures. Lowering is included with its scenario; qualification, sequence numbers, and unrelated generated code are simplified.

Bind a text value or a checkbox

Before and after adoption, the application writes the same Razor:

<input @bind="customer.Name" />
<input type="checkbox" @bind="customer.IsActive" />

For these known framework types, Razor uses direct framework conversion without introducing a cascading property or constructing a converter per binding. The text binding has this lowering shape:

builder.OpenElement(0, "input");
builder.AddAttribute(1, "value", BindConverter.FormatValue(customer.Name));
builder.AddAttribute(2, "onchange",
    EventCallback.Factory.CreateBinder(
        this, __value => customer.Name = __value, customer.Name));
builder.SetUpdatesAttributeName("value");
builder.CloseElement();

The checkbox binding preserves Boolean checked values:

builder.OpenElement(0, "input");
builder.AddAttribute(1, "type", "checkbox");
builder.AddAttribute(2, "checked", BindConverter.FormatValue(customer.IsActive));
builder.AddAttribute(3, "onchange",
    EventCallback.Factory.CreateBinder(
        this, __value => customer.IsActive = __value, customer.IsActive));
builder.SetUpdatesAttributeName("checked");
builder.CloseElement();

False remains a Boolean conditional attribute value, not the truthy text "false". These framework paths must not acquire broad preservation requirements merely because BindConverter also supports custom types.

Preserve culture, formatting, and callbacks
<input @bind="amount" @bind:culture="culture" />
<input @bind="date" @bind:format="yyyy-MM-dd" />
<input @bind:get="name" @bind:set="SetNameAsync" />
<input @bind="searchText" @bind:after="SearchAsync" />

Supported culture and format choices retain their existing meaning. Asynchronous setters and after-callbacks retain their supported completion, ordering, and error behavior.

For a decimal amount, the relevant lowering is:

var attributeValue = BindConverter.FormatValue(amount, culture);
var callback = EventCallback.Factory.CreateBinder(
    this, __value => amount = __value, amount, culture);

For the asynchronous name setter, the factory receives the asynchronous setter rather than a replacement assignment:

var callback = EventCallback.Factory.CreateBinder(
    this, SetNameAsync, name);

Supported format values reach both applicable formatting and parsing operations. After-callbacks retain their existing compiler-generated setter/callback composition.

Use well-known framework conversion

The framework provides reusable conversion for a bounded default inventory rather than generating one implementation per binding:

Category Default coverage
Text string
Boolean bool and bool?, preserving conditional-attribute versus select-text behavior
Numeric direct paths short, int, long, float, double, decimal, and their nullable versions
Date/time DateTime, DateTimeOffset, DateOnly, TimeOnly, and their nullable versions
Identifier Guid and Guid?, preserving supported parsing/formatting
Common arrays Supported one-dimensional arrays of the common scalar types, including nullable value-type elements and string[]
Enums Shared trim/AOT-compatible Enum operations, including Type-based operations for unconstrained generic callers and nullable enums

This list reflects existing direct conversion cases rather than assuming every C# primitive already has a dedicated path. Additional primitive types such as byte, sbyte, ushort, uint, ulong, and char require coverage review against their existing converter behavior before inclusion; their existing supported behavior must not be lost.

Common nullable and one-dimensional array combinations can use a static table of closed implementations. Reference nullability does not require separate runtime conversion instances. Supported one-dimensional arrays of enum or custom-convertible elements need statically available element conversion rather than runtime MakeGenericMethod/MakeGenericType.

For a statically known enum, shared conversion can use Enum.TryParse and Enum.IsDefined. Unconstrained generic code can use the existing Type-based enum operations instead of closing a constrained generic method through reflection. Parsing retains current case sensitivity, empty-input behavior, and rejection of undefined enum values, including unnamed flags combinations.

For example:

<select @bind="customer.Status">
    <option value="Active">Active</option>
    <option value="Inactive">Inactive</option>
</select>
<select @bind="selectedStatuses" multiple>
    <option value="Active">Active</option>
    <option value="Inactive">Inactive</option>
</select>

Here Status is an OrderStatus enum and selectedStatuses is an OrderStatus array. These native element bindings let Razor see the concrete conversion types directly, unlike conversion inside a generic input component. The single-selection lowering has this shape:

builder.OpenElement(0, "select");
builder.AddAttribute(1, "value", BindConverter.FormatValue(customer.Status));
builder.AddAttribute(2, "onchange",
    EventCallback.Factory.CreateBinder<OrderStatus>(
        this, __value => customer.Status = __value, customer.Status));
builder.SetUpdatesAttributeName("value");
// Option content is omitted.
builder.CloseElement();

For multiple selection, the event carries a string array. Conversion allocates a target OrderStatus array and converts each selected string using the shared enum conversion. Razor can statically establish both the target array and its element conversion; it must not cause runtime reflected generic construction.

The existing BindConverter array implementation creates its element-parser factory through MakeGenericMethod/Invoke. This feature replaces that discovery/construction while preserving element order, empty-array results, failure behavior, and supported formatting. Concrete-array static APIs and generic-array converter delivery remain integration questions, not evidence that arrays already satisfy strict execution.

Bind a statically known custom type

A custom type associates its converter using System.ComponentModel.TypeConverterAttribute:

[TypeConverter(typeof(MoneyConverter))]
public readonly record struct Money(decimal Amount);

MoneyConverter implements supported string conversion through TypeConverter. Razor can inspect the attribute's typeof argument as a type symbol and reference the converter directly:

<input @bind="invoice.Total" />

For a supported accessible parameterless constructor, the generated component can retain a converter field:

private readonly TypeConverter __moneyConverter = new MoneyConverter();

The corresponding proposed lowering supplies that converter to both directions:

builder.OpenElement(0, "input");
builder.AddAttribute(1, "value",
    BindConverter.FormatValue(
        invoice.Total, converter: __moneyConverter,
        culture: null, format: null));
builder.AddAttribute(2, "onchange",
    EventCallback.Factory.CreateBinder<Money>(
        this, __value => invoice.Total = __value, invoice.Total,
        converter: __moneyConverter, culture: null, format: null));
builder.SetUpdatesAttributeName("value");
builder.CloseElement();

No TypeDescriptor.GetConverter call, generated converter class, generic binder interface, or cascading request is needed at this known-type binding site. The field illustrates reuse outside the render method, not a finalized lifetime/caching policy.

Converter-backed formatting and parsing preserve applicable direction, culture, failure, and null/empty behavior. TypeConverter does not itself define the complete binding format contract; the framework must preserve existing format support without promising that arbitrary converters accept format strings.

Supply a custom converter to generic code

A non-generic TypeConverter cannot by itself identify which generic target type a cascading request serves. A typed carrier supplies that association without implementing conversion:

public sealed class TypeConverterAttributeBinder<T>
{
    public TypeConverterAttributeBinder(TypeConverter value)
    {
        ArgumentNullException.ThrowIfNull(value);
        Value = value;
    }

    public TypeConverter Value { get; }
}

T identifies the target type for lookup and cascading matching. Value returns the converter; the class has no parsing or formatting methods. Direct generated construction does not require a preservation annotation on T because the carrier does not discover its members.

A generic component can originate a binding to local state:

@typeparam TValue

<input @bind="localValue" />

@code {
    [CascadingParameter]
    internal TypeConverterAttributeBinder<TValue>? AttributeConverter { get; set; }

    private TValue localValue = default!;
}

An application uses the closed component without supplying a bound value:

<GenericEditor TValue="Money" />

Framework conversion handles common types and enums directly. When TValue needs custom conversion, a fixed cascading source supplies TypeConverterAttributeBinder, constructed from the already-known MoneyConverter. Components do not receive a resolver and do not call TypeDescriptor to discover converters.

Razor recognizes the matching accessible cascading property and uses its Value when supplied. Proposed generic lowering is:

var converter = AttributeConverter?.Value;

builder.OpenElement(0, "input");
builder.AddAttribute(1, "value",
    BindConverter.FormatValue(
        localValue, converter: converter, culture: null, format: null));
builder.AddAttribute(2, "onchange",
    EventCallback.Factory.CreateBinder<TValue>(
        this, __value => localValue = __value, localValue,
        converter: converter, culture: null, format: null));
builder.SetUpdatesAttributeName("value");
builder.CloseElement();

In this proposed overload shape, an absent converter permits only framework-supported conversion. If custom conversion is required and not supplied, strict execution reports missing support rather than switching to TypeDescriptor discovery. The nullable property allows framework-supported generic cases to avoid an unnecessary custom-converter source; exact overload and compiler-selection rules remain open.

Fixedness belongs to the supplier, not CascadingParameterAttribute. The supplied carrier/converter reference stays stable for the source's lifetime, avoiding recipient change subscriptions; culture and format remain per-operation arguments. A stable reference does not prove that the converter is stateless or thread-safe.

Closed carrier instances or factories can be recorded in the selected application's definitions and supplied through CascadingValue or equivalent framework integration. Dictionary lookup and cascading matching retain the target type identity; supplying the wrong converter remains a runtime contract error because TypeConverter returns object rather than T.

Integrate framework and custom input components

Generic inputs need conversion even when their parent only forwards typed values:

@typeparam TValue

<InputSelect TValue="TValue" @bind-Value="localValue" />

@code {
    private TValue localValue = default!;
}

The relevant wrapper lowering remains:

builder.OpenComponent<InputSelect<TValue>>(0);
builder.AddComponentParameter(1, "Value", localValue);
builder.AddComponentParameter(2, "ValueChanged",
    EventCallback.Factory.Create<TValue>(
        this, __value => localValue = __value));
// Other existing component parameters are omitted.
builder.CloseComponent();

InputSelect performs conversion internally. If its TValue needs a custom converter, the input declares its own typed cascading carrier property; the wrapper does not forward the converter as an ordinary parameter. The same mechanism is available to custom inputs:

[CascadingParameter]
internal TypeConverterAttributeBinder<TValue>? AttributeConverter { get; set; }

A component that only forwards Value and ValueChanged needs no converter merely because it is generic. An input that performs conversion uses the supplied converter with the framework conversion APIs, preserving its local state, validation messages, and notifications. Existing InputBase formatting/parsing overrides remain supported; their detailed interaction with supplied converters needs a defined policy.

InputSelect selects its multiple-selection path when TValue is an array. It receives a string array from the element callback, then calls BindConverter to convert the elements to the target array type. For custom-convertible array elements, the required converter belongs to the element type, not necessarily the array type; generic discovery of that element requirement and statically available array conversion must be designed explicitly. Single-select formatting currently defaults to InputBase's ToString-based behavior, with InputSelect Boolean specialization; supplying a converter must not silently replace that with converter-based formatting.

Diagnose unsupported conversion and preserve ordinary invalid input

Invalid element input retains the existing conversion-failure and empty-input setter behavior. InputBase-derived inputs retain rejected text, parsing validation messages, local value updates, and field/validation notifications rather than writing invalid values to the model.

A missing custom converter, unsupported constructor/association, or converter result of the wrong type is a support/configuration error, not ordinary invalid user input. Statically knowable missing/unsupported requirements produce actionable build diagnostics naming the affected type/member/binding and corrective action. Runtime-only support failures report explicitly instead of falling back to reflection or appearing successful; they must not be swallowed as an ordinary parse failure.

Statically visible TypeConverterAttribute(typeof(...)) associations are the baseline. String-named attributes, inherited associations, and runtime TypeDescriptor registration/providers require explicit support rules. Directly constructing a converter removes discovery, but its implementation must itself be trim/AOT-compatible; preservation annotations cannot guarantee that or make runtime code generation available.

Upgrade without enabling additional AOT features

Existing applications retain their binding syntax, supported converters, and custom inputs without mandatory new AOT-related declarations or configuration. Upgrading alone introduces no new build/publish warnings. Strict diagnostics and generated-support requirements apply when additional AOT features or checks are enabled.

Dominant language
C#
Stars
38.5k
Forks
11.6k
Avg merge
3d 1h
Merged PRs (30d)
289

Getting set up

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/aspnetcore

All issues in dotnet/aspnetcore

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.