Make Blazor component descriptor serialization Native AOT and trimming compatible

Open
#70,432 1 comment 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
25/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Active
Tech stack
csharp
Domain
backend

Research direction

Start with the linked BlazorSerializer contract in issue #70407 and the Blazor Native AOT epic in #70364, then trace the current root-type cache and descriptor writer and reader mentioned in this issue. Map how component and parameter identities, selected serializer options, and existing validation flow through those entry points. Done means supported descriptors resolve through known mappings and preserve concrete parameter values across the described activation and update scenarios.

Written by the indexing model from the issue text.

Description

area-blazor NativeAOT

Make Blazor component descriptor serialization Native AOT and trimming compatible

Summary

Use the proposed BlazorSerializer and BlazorSerializerOptions contract to serialize and restore component descriptor parameter values, and resolve both component identities and concrete parameter identities through known string-to-type mappings, preferably generated at compile time.

A descriptor identifies the root component to activate or update and carries its parameter values. Component identity maps to an eligible component Type consumed by the existing component runtime. Each concrete parameter identity maps to a value Type consumed by the shared serializer. The component instance itself is not deserialized as an application model.

This is Serialization item 3 of the Blazor Native AOT epic. Component discovery supplies supported component definitions and their mapping contributions; descriptor consumers must use those mappings when writing and reading descriptors. Application values use the selected application's serialization definitions. A host may register multiple applications, each with its own selected configuration.

Proposal: API names below are proposed or drawn from the shared proposal, not approved APIs. Declaration syntax, identity encoding/versioning, and shared host-binding contracts remain open.

Goals

  1. Restore supported descriptors for initial activation, root updates, and applicable circuit root-marker restoration without arbitrary assembly loading, type-name resolution, or member discovery in strict execution.
  2. Preserve concrete root-parameter types and supported data, including an Employee passed to a Person parameter, ordinary null, and typed values whose representation is null.
  3. Reuse application serialization definitions through BlazorSerializerOptions, including compatible generated STJ definitions and byte-provider definitions, without a second descriptor-specific provider registration.
  4. Isolate multiple mapped applications: each application's endpoints and associated circuits retain its component mappings, parameter mappings, and finalized serializer selection.
  5. Preserve supported defaults for ordinary upgrades without new warnings or required declarations, along with existing Server protection/validation, browser-safe carriers, supported captured content, and error-reporting conventions.

Non-goals

  1. Define how discovery produces component metadata, or redesign component construction, injection, assignment, or lifecycle execution. Consuming known component mappings is required here; generating/discovering component definitions remains a separate integration boundary.
  2. Integrate JS interop serialization or add dedicated DynamicComponent support.
  3. Redesign persistent state, session, or TempData value serialization. Shared initialization must precede their immediate serialization callbacks, but their representations and lifecycle rules are separate.
  4. Replace descriptor, renderer, or host protocols wholesale, or permit application providers to override framework envelope fields.
  5. Serialize arbitrary delegates or configure nested-model polymorphism. Preserve currently supported captured render content without expanding it.

Proposed solution

There are two logical identity-mapping roles:

Mapping Input and output Consumer
Component identity Known string identity to eligible root component Type, with matching writer identity Existing component runtime
Concrete parameter identity Known string identity to supported concrete value Type, with matching writer identity BlazorSerializer with selected options

These roles may share registry storage or generation machinery, but remain independently eligible. A type is not an allowed root component merely because it has a serializer definition, appears in the parameter registry, or implements IComponent. Physical sharing versus separate registry stores remains open.

Both roles are public interface contracts implemented by source-generated application/library code: proposed names are IComponentTypeRegistry and IComponentParameterTypeRegistry. Generated implementation classes and framework caches may remain internal. Public contracts, rather than access to internal framework registry classes, provide the cross-assembly boundary.

Component mappings compose supported application and library component definitions, including non-routable roots introduced by interactive boundaries. Parameter mappings include statically established parameter types and explicitly declared additional concrete runtime selections. The additional declarations are application-wide, through an attribute or equivalent mechanism whose syntax remains open. They ensure both the known mapping and the proper serialization descriptor are available on every applicable receiving side; preserving a type name alone is insufficient.

For non-null parameter values, writers select the concrete type using the actual value's runtime type. Readers resolve the known identity to Type, then select its matching definition through the application's options. A broad resolver is not an enumeration of all allowed incoming descriptor types. Missing support must not silently use a declared base type, lose derived data, or invoke unsafe discovery.

Blazor owns the descriptor envelope and all component identities, sequence numbers, invocation IDs, keys, routing fields, and transport validation. Providers own only supported application-value slots. The shared internal STJ bridge preserves definition selection throughout those slots; its byte-provider Base64 carrier is distinct from Server protection/transport or WebAssembly HTML-comment Base64. Native STJ definitions do not become provider blobs simply because they occur in a descriptor.

flowchart TD
    Host[Application-aware host selection] --> Components[Known component mappings]
    Host --> Parameters[Known parameter mappings]
    Host --> Options[BlazorSerializerOptions]
    Writer[Descriptor writer] --> Components
    Writer --> Parameters
    Writer --> Serializer[BlazorSerializer]
    Serializer --> Options
    Writer --> Envelope[Framework envelope and transport]
    Envelope --> Reader[Descriptor reader]
    Reader --> Components
    Reader --> Parameters
    Reader --> Serializer
    Reader --> Runtime[Existing component runtime]

Scenarios and corresponding design

1. Resolve the component type in a descriptor

A root component does not need application parameters:

<StatusPanel @rendermode="InteractiveServer" />

The selected application's mappings identify the supported StatusPanel type. Before this change, the current root-type cache resolves assembly/type-name fields using runtime discovery. The strict/generated path instead resolves the descriptor identity through known mappings and hands the type to the existing component runtime.

sequenceDiagram
    participant G as Generated component contribution
    participant M as Component mappings
    participant W as Descriptor writer
    participant D as Descriptor reader
    participant C as Existing component runtime
    G->>M: Contribute supported application and library roots
    W->>M: Get identity for component Type
    M-->>W: Known string identity
    W-->>D: Framework descriptor
    D->>D: Apply protocol validation
    D->>M: Resolve component identity
    M-->>D: Eligible Type or no match
    D->>C: Deliver known component Type and empty parameters

Outcome: Supported routable and non-routable roots resolve without type-name discovery. Unknown or ineligible identities fail before activation; no-match does not proceed to the runtime. Discovery owns producing the component definitions, not an exemption from resolving component identities in this consumer.

2. Pass an ordinary parameter across a Server boundary

Existing component usage remains unchanged:

<CustomerSummary Customer="customer"
                 @rendermode="InteractiveServer" />

The same code retains supported defaults. When the application supplies a supported definition through its selected options, descriptor writes and reads use that definition.

sequenceDiagram
    participant W as Descriptor writer
    participant M as Component mappings
    participant R as Parameter mappings
    participant B as BlazorSerializer
    participant D as Descriptor reader
    participant C as Existing component runtime
    W->>M: Get component identity
    W->>R: Get concrete parameter identity
    W->>B: Write concrete value with selected options
    W-->>D: Protected framework descriptor
    D->>D: Apply existing validation
    D->>M: Resolve component Type
    D->>R: Resolve concrete value Type
    D->>B: Read bounded value with selected options
    D->>C: Deliver component Type and ParameterView

Outcome: Both identities resolve through their appropriate known mapping. The existing runtime still constructs the component, assigns parameters, and runs its lifecycle.

3. Reuse an application serializer provider

Application configuration uses the shared contract:

options.TypeInfoResolvers.Add(new MessagePackResolver(options));

MessagePackResolver is an illustrative provider integration from the shared proposal, not a shipping framework class. Before descriptor adoption, configuring this shared resolver alone does not wire the existing descriptor serializers. After adoption, it handles the supported known parameter types assigned to it.

sequenceDiagram
    participant D as Descriptor value consumer
    participant B as BlazorSerializer
    participant O as Selected options
    participant P as Selected provider
    D->>B: Write known concrete Type
    B->>O: Select matching definition
    B->>P: Write provider bytes
    P->>B: Delegate supported child with captured options
    B-->>D: Complete value in framework slot
    D->>B: Read using matching Type and options
    B->>P: Read decoded provider bytes
    B-->>D: Restored value

Outcome: No second descriptor-specific serializer registration is required. An existing STJ context implementing IBlazorTypeInfoResolver through application-authored integration participates through the same options. This feature does not generate that interface implementation. Nested provider and framework handling must remain effective; a selected provider failure is not retried through reflection.

4. Preserve a declared additional runtime type

A component declares a base-type parameter:

[Parameter]
public Person? Person { get; set; }

The caller supplies a value that may contain an Employee:

<PersonSummary Person="selectedPerson"
               @rendermode="InteractiveServer" />

For strict/generated execution, the application explicitly declares Employee as an additional supported root-parameter type through the application-wide declaration mechanism. The declaration's syntax remains open; it must establish both mapping eligibility and serialization support.

sequenceDiagram
    participant G as Generated contribution
    participant R as Parameter mappings
    participant W as Descriptor writer
    participant B as BlazorSerializer
    participant D as Parameter reader
    G->>R: Include explicitly declared Employee
    W->>R: Get identity for actual runtime Type
    R-->>W: Employee identity
    W->>B: Write as Employee
    W-->>D: Concrete identity and complete value
    D->>R: Resolve Employee identity
    R-->>D: typeof(Employee)
    D->>B: Read as Employee

Outcome: Derived data and concrete type are retained. Undeclared additional runtime types or unavailable definitions fail clearly in strict execution, rather than silently using Person. Ordinary statically established parameter types do not require manual declarations. This does not configure nested-model polymorphism.

5. Round-trip null and typed encoded-null values

An application may pass an ordinary null:

<PersonSummary Person="@((Person?)null)"
               @rendermode="InteractiveServer" />

Separately, a non-null concrete value may have a supported serializer representation of null.

sequenceDiagram
    participant W as Descriptor writer
    participant D as Parameter reader
    participant R as Parameter mappings
    participant B as BlazorSerializer
    alt CLR value is null
        W-->>D: Null without concrete identity
        D->>D: Restore null under framework rule
    else Non-null value encodes as null
        W-->>D: Concrete identity and encoded null
        D->>R: Resolve concrete Type
        D->>B: Read null representation with selected definition
    end

Outcome: Untyped null stays distinct from a typed encoded-null value. The latter must execute its concrete definition, including existing supported union behavior.

6. Restore WebAssembly and Auto parameters

The same supported model may cross a browser-runtime boundary:

<CustomerSummary Customer="customer"
                 @rendermode="InteractiveWebAssembly" />

Changing the render mode to InteractiveAuto supports either applicable receiving runtime. Server and client need compatible component mappings, parameter mappings, and serialization definitions; they do not share a live options object.

sequenceDiagram
    participant W as WebAssembly descriptor writer
    participant H as Client host setup
    participant M as Client component mappings
    participant D as Client parameter reader
    participant R as Client parameter mappings
    participant B as BlazorSerializer
    participant C as Existing component runtime
    W-->>H: HTML-safe descriptor data
    H->>H: Retain raw data until configuration exists
    H->>M: Resolve known component identity
    M-->>H: Eligible client component Type
    H->>D: Restore initial parameter values
    D->>R: Resolve concrete value identity
    D->>B: Read with selected client options
    H->>C: Deliver resolved component and restored parameters

Outcome: Initial and later reads use configured client selection, not an early static default. Non-routable library components require client component mappings too. Missing or incompatible support fails explicitly. HTML-comment transport remains safe even when values contain comment delimiters.

7. Isolate multiple mapped applications and later updates

Two applications in one host may use different definitions for the same CLR model. Each application has one selected configuration for its endpoints and associated circuits, typically sourced through endpoint metadata or an equivalent application-aware mechanism.

sequenceDiagram
    participant H as Initial connection endpoint metadata
    participant U as ComponentHub
    participant F as CircuitFactory
    participant D as Circuit descriptor readers
    U->>H: Read application, options and both registry roles
    H-->>U: Selected application configuration
    U->>D: Restore applicable initial descriptors with selection
    U->>F: Create scope with application association
    F->>F: Initialize options and both mapping roles
    U->>D: Later roots or updates for associated circuit
    D->>D: Retain owning application's selection

Outcome: Initial roots, roots supplied after an empty start, updates, reconnects, and applicable restored markers retain the application's selection. A component known only to another application is not implicitly eligible. Concurrent or nested calls cannot change another operation's options, provider caches, mapping eligibility, or mutable operation state.

8. Compose library types and supported captured content

Libraries contribute supported component definitions, ordinary parameter models, and serialization definitions. Applications include additional runtime-selected library parameter types in their application-wide declarations. Conflicting identities must fail rather than select the first loaded contribution.

sequenceDiagram
    participant G as Application and library contributions
    participant M as Component mapping composition
    participant R as Parameter mapping composition
    participant F as RenderFragmentSerializer
    participant B as BlazorSerializer
    G->>M: Contribute supported component identities
    G->>R: Contribute supported value identities
    M->>M: Validate component-role conflicts
    R->>R: Validate parameter-role conflicts
    F->>B: Write or read supported nested application values

Outcome: Component and parameter contributions compose without assembly scanning and preserve their role-specific eligibility. Existing captured content keeps its framework DTOs and restrictions; nested component identities use component mappings and nested application values use parameter definitions. Arbitrary delegates and unsupported templated content do not become serializable.

9. Reject invalid or unsupported descriptors

Server restoration retains data protection, expiration, marker-key validation, invocation-ID checks, and sequence/reuse checks under existing rules.

sequenceDiagram
    participant D as Descriptor readers
    participant M as Component mappings
    participant R as Parameter mappings
    participant B as BlazorSerializer
    participant E as Existing rejection or exception surface
    D->>D: Validate envelope, definitions and counts
    alt Invalid framework descriptor
        D->>E: Reject under existing rules
    else Unknown or ineligible component
        D->>M: Resolve component identity
        M-->>D: No eligible match
        D->>E: Report unsupported root component
    else Unknown parameter identity
        D->>R: Resolve concrete identity
        R-->>D: No match
        D->>E: Report unsupported value type
    else Missing definition or invalid value
        D->>B: Read selected concrete definition
        B-->>D: Explicit failure
        D->>E: Report without reflection retry
    end

Outcome: Server logging/rejection and WebAssembly exception conventions remain explicit. Errors identify the relevant component or parameter and identity where safe, without logging sensitive payload values. Unknown identities, wrong-role entries, incomplete definitions, mismatched counts, malformed values, and provider failures cannot yield success-shaped defaults.

Registry and serializer contracts

The following public interface declarations express the mapping roles supplied by generated application/library code. Interface-based extensibility is selected; exact names/signatures remain proposed. The declarations do not select separate physical stores or final registration APIs.

public interface IComponentTypeRegistry
{
    Type? ResolveType(string identity);
    string? GetIdentity(Type componentType);
}
public interface IComponentParameterTypeRegistry
{
    Type? ResolveType(string identity);
    string? GetIdentity(Type type);
}

Implementations provide stable known mappings after configuration finalization without requiring a public dictionary or framework registry constructor. Generated code may use direct lookups or precomputed tables; a single implementation may implement both interfaces while preserving role eligibility. Framework wrappers/caches may remain internal and owned by the selected application.

Conflicting identity-to-type or type-to-identity contributions must fail explicitly. Proposed composition rules allow identical duplicate contributions to coalesce and use one canonical writer identity per type per role. Missing lookups return no match and require caller-side failure reporting. A type present in both roles must meet both requirements independently. The lookup-only interfaces do not enumerate contributions, so whole-registry validation cannot be assumed. Generated composition can validate known entries; validation of independently registered implementations and the final registration/composition mechanism remain open.

Component contributions come from supported discovery definitions. Parameter contributions include statically established types plus explicit additional runtime declarations. Generation/configuration must establish the required serialization support; emitting only a string-to-type token does not suffice. Generated code implements the public interfaces across assembly boundaries without accessing internal framework types; registration, composition, and final package placement remain open.

Value execution reuses the relevant proposed shared operations:

public static class BlazorSerializer
{
    public static void Serialize(
        IBufferWriter<byte> destination, object? value,
        Type type, BlazorSerializerOptions options);

    public static object? Deserialize(
        ReadOnlySequence<byte> input, Type type,
        BlazorSerializerOptions options);
}
public sealed class BlazorSerializerOptions
{
    public IList<IBlazorTypeInfoResolver> TypeInfoResolvers { get; }
    public bool IsReadOnly { get; }
    public void MakeReadOnly();
    public BlazorTypeInfo GetTypeInfo(Type type);
}

These are API-reference excerpts from the shared proposal, not implementations or replacement declarations for its complete surface. No new public provider protocol is added.

The existing assembly/full-name descriptor fields are not an approved new registry encoding. Adapting those fields to the logical string key, retaining or changing the wire representation, version compatibility, and canonicalization remain open. No name parser, assembly load, or Type.GetType fallback is implied in strict execution.

Host and application integration

Surface Required integration
SSR initialization Select the mapped application's configuration, typically from endpoint metadata; initialize access to serializer options and both mapping roles before value consumers or immediate state-restoration callbacks.
Server writers Use selected component identities and parameter definitions without capturing request-selected state in the existing singleton writer.
Server readers Replace strict component name-based discovery and parameter name-based discovery with their selected known mappings, retaining framework protocol validation.
Hub/circuit creation Read the owning application's configuration from endpoint metadata on the initial connection before initial descriptor restoration, then carry that selection into circuit initialization, including empty starts.
Subsequent roots/reconnects/restoration Retain or re-establish the owning application under the compatible configuration policy; reject cross-application updates rather than switch configurations.
WebAssembly startup Supply configured client selection before initial component/parameter resolution and value restoration; both initial and later reads must stop using configuration-independent static dispatch.
Captured content Preserve framework representation and restrictions while resolving supported nested component and value identities through their respective roles.
Existing runtime Receive the resolved component Type and ParameterView; construction, injection, assignment, and lifecycle remain unchanged.

Assume all information needed to identify the owning application and select serializer options and both registry roles is available in endpoint metadata readable on the initial connection. ComponentHub.StartCircuit reads this selection before restoring initial descriptors and passes it into CircuitFactory for the circuit to retain. The existing pre-circuit descriptor reads do not, by themselves, require splitting framework validation from value restoration or moving restoration into the circuit scope.

WebAssemblyHostBuilder currently restores registered root parameters during early builder initialization before application DI exists. The design proposes retaining raw descriptors until configured client services are available. Observable RootComponents behavior must be preserved or any necessary behavior change explicitly resolved before implementation.

DI exposes the selected application's configuration; one host-wide options registration cannot represent multiple independent applications. The host supplies the required application association through the initial connection's endpoint metadata; recovering the original SSR endpoint or defining another association protocol is not part of this proposal. Selected configuration must also precede state-restoration callbacks that deserialize immediately, not merely precede rendering.

Finalized options are reusable configuration, not an active runtime or STJ operation stack. Providers that capture options and their native formatter caches belong to that configuration; shallow copies must not retain providers bound to another options instance. Scope-bound runtime association and concurrent/reentrant operation state must remain isolated without global mutable selection.

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.