Skip to content

[Blazor] Generate component metadata for Native AOT - #68299

Draft
javiercn wants to merge 17 commits into
javiercn-aot-stack-3-bindingfrom
javiercn-component-metadata-aot
Draft

[Blazor] Generate component metadata for Native AOT#68299
javiercn wants to merge 17 commits into
javiercn-aot-stack-3-bindingfrom
javiercn-component-metadata-aot

Conversation

@javiercn

@javiercn javiercn commented Aug 9, 2026

Copy link
Copy Markdown
Member

Overview

This is layer 4 of 6 for #68332, stacked on #68297. It gives component activation, member access, routing/discovery, and Server root operations one shared ComponentTypeInfo model, then teaches the application metadata generator to produce that model at compile time. The governing constraint is generated first, reflection last: registered metadata avoids reflection, while applications without metadata continue to work unchanged; framework-owned descriptor providers and strict reflection disablement remain in later stack layers.

Design

The new experimental contract describes a component as executable metadata rather than as reflected members:

// src/Components/Components/src/Infrastructure/ComponentDescriptor.cs
// One complete application-component description: construction, bindable members, injection,
// and the attribute-shaped metadata already consumed by routing/rendering.
[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
public sealed class ComponentDescriptor
{
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
    public required Type Type { get; init; }
    public Func<IServiceProvider, IComponent>? CreateInstance { get; init; }
    public IReadOnlyList<ComponentParameterDescriptor> Parameters { get; init; } = [];
    public IReadOnlyList<ComponentInjectableDescriptor> Injectables { get; init; } = [];
    public IReadOnlyList<object> Metadata { get; init; } = [];
}

ComponentParameterDescriptor carries the declared type, role attribute, generated getter/setter delegates, and an optional closed persistent-state serializer factory. ComponentInjectableDescriptor is separate because injection has different semantics: service type, keyed [Inject] metadata, and a setter, but no getter or unmatched-value behavior. The open metadata bag carries route, layout, render-mode, endpoint, and future attributes without expanding the public API for each feature.

The existing application metadata context gains one capability and keeps the lower-layer JSON, JS-invokable, and bindable contracts:

// src/Components/Web/src/Metadata/RazorComponentsMetadataContext.cs
public abstract class RazorComponentsMetadataContext
{
    public abstract IReadOnlyList<ComponentDescriptor> Components { get; }
    public abstract IReadOnlyList<BindableTypeDescriptor> BindableTypes { get; }
    public abstract IReadOnlyList<JSInvokableMethodDescriptor> JSInvokableMethods { get; }
    public abstract IJsonTypeInfoResolver? JsonTypeInfoResolver { get; }
}
// src/Components/Web/src/Metadata/ComponentMetadataServiceCollectionExtensions.cs
public static IServiceCollection AddComponentMetadata<TContext>(this IServiceCollection services)
    where TContext : RazorComponentsMetadataContext, new()
{
    var context = new TContext();
    services.Configure<ComponentDescriptorOptions>(options =>
    {
        foreach (var component in context.Components)
        {
            options.Components.Add(component);
        }
    });

    services.AddSingleton<RazorComponentsMetadataContext>(context);
    services.TryAddSingleton<IComponentMetadataResolver>(
        static services => services.GetRequiredService<ComponentMetadataResolver>());
    services.TryAddSingleton<IComponentTypeInfoResolver>(ComponentTypeInfoResolverFactory.Create);
    return services;
}

Two decisions preserve compatibility:

  • A component is generated completely or not at all. A partial descriptor could silently omit a parameter or injected property because runtime consumers trust the descriptor's member lists. Unsupported/inaccessible application components receive a diagnostic and remain on reflection.
  • The runtime owns the component-reflection feature switch, but its default stays true. This layer defines generated-only resolver/failure semantics; the final strict layer is responsible for setting the switch to false.
// src/Components/Components/src/ComponentTypeInfoResolverFactory.cs
internal static IComponentTypeInfoResolver Create(IServiceProvider services)
{
    var resolvers = new List<IComponentTypeInfoResolver>();
    if (services.GetService<IComponentMetadataResolver>() is { } metadataResolver)
    {
        resolvers.Add(new SourceGeneratedComponentTypeInfoResolver(metadataResolver));
    }

    if (ComponentMetadataFeature.IsReflectionEnabledByDefault)
    {
        resolvers.Add(new ReflectionComponentTypeInfoResolver());
    }

    return new CompositeComponentTypeInfoResolver(resolvers);
}

Framework assemblies are intentionally skipped by the application collector. The next stack layer supplies owner-authored framework descriptors and factories; this PR only establishes the resolver/merge contract they consume.

Implementation

A renderer creates one resolver chain and gives it to activation, property injection, parameter binding, cascading state, persistence, discovery, and root operations. The source-generated resolver is first; the composite caches type/name/assembly lookups, can fill a missing generated construction factory from a later resolver, and invalidates caches for hot reload. If both generated and reflection resolvers exist, hot reload disables stale generated descriptors and continues through reflection.

// src/Components/Components/src/RenderTree/Renderer.cs
var registeredTypeInfoResolver = serviceProvider.GetService<IComponentTypeInfoResolver>();
ComponentTypeInfoResolver = registeredTypeInfoResolver
    ?? ComponentTypeInfoResolverFactory.Create(serviceProvider);

componentActivator ??= serviceProvider.GetService<IComponentActivator>()
    ?? new DefaultComponentActivator(serviceProvider, ComponentTypeInfoResolver);
var propertyActivator = serviceProvider.GetService<IComponentPropertyActivator>()
    ?? new DefaultComponentPropertyActivator(ComponentTypeInfoResolver);
_componentFactory = new ComponentFactory(componentActivator, propertyActivator, this);

The generator walks referenced application types with private metadata imported because Razor-generated @inject properties and render-mode attributes are not visible under Roslyn's default import mode. It preserves most-derived member semantics, reconstructs component attributes, and rejects the whole component if any required member cannot be reached.

// src/Components/Endpoints/gen/RazorComponentsMetadataGenerator.Components.cs
// A partial descriptor is unsafe: consumers would trust incomplete parameter/injection lists.
if (!TryDescribeComponent(type, types, generatedIn, diagnostics, out var model, out var reason))
{
    if (!string.IsNullOrEmpty(reason))
    {
        diagnostics.Add(new DiagnosticInfo(
            DiagnosticDescriptors.ComponentNotFullyDescribed.Id,
            type.FullName(),
            reason));
    }

    continue;
}

Generated parameter descriptors cover ordinary, cascading/query/session/TempData, and persistent parameters. Their deltas are only the reconstructed attribute and optional serializer/accessor bridge:

// src/Components/Endpoints/gen/Emitters/RazorComponentsMetadataGenerator.Emitter.cs
writer.WriteLine($"Name = {SymbolHelpers.ToStringLiteral(parameter.Name)},");
writer.WriteLine($"ParameterType = typeof({parameter.PropertyTypeFullyQualifiedName}),");
writer.WriteLine($"Attribute = {parameter.AttributeExpression},");
writer.WriteLine($"SetValue = static (__target, __value) => {write},");
writer.WriteLine($"GetValue = static __target => {read},");

if (parameter.IsPersistentState)
{
    // T is known at compile time, avoiding MakeGenericType at runtime.
    writer.WriteLine(
        $"GetStateSerializer = static __services => __services.GetService(" +
        $"typeof({StateSerializerType}<{parameter.PropertyTypeFullyQualifiedName}>)),");
}

Runtime entry points retain renderer-scoped type info instead of rediscovering members. ComponentBase binds through the renderer; ComponentFactory uses descriptor render modes, activation, and injection; Session and TempData share descriptor-first value access instead of maintaining independent reflection caches.

// src/Components/Components/src/ComponentBase.cs
public virtual Task SetParametersAsync(ParameterView parameters)
{
    parameters.SetParameterProperties(this, _renderHandle);
    if (!_initialized)
    {
        _initialized = true;
        return RunInitAndSetParametersAsync();
    }

    return CallOnParametersSetAsync();
}
// src/Components/Endpoints/src/ComponentParameterValueGetter.cs
internal static Func<object?> Create(object component, ComponentTypeInfo typeInfo, string propertyName)
{
    var descriptor = FindParameter(typeInfo, propertyName);
    if (descriptor is null && ComponentMetadataFeature.IsReflectionEnabledByDefault)
    {
        descriptor = FindParameter(
            ComponentTypeInfoResolverFactory.Default.GetRequiredTypeInfo(component.GetType()),
            propertyName);
    }

    if (descriptor is null)
    {
        throw new InvalidOperationException(
            $"A property '{propertyName}' on component type '{component.GetType().FullName}' wasn't found.");
    }

    return () => descriptor.GetValue(component);
}

Discovery is the assembly-enumeration equivalence class. Routing and endpoint discovery enumerate ComponentTypeInfo, route-cache identity includes the resolver, and endpoint metadata is the descriptor metadata minus RouteAttribute. Layout and render-mode consumers read the same metadata bag, so the old assembly/component/page builder graph is removed.

// src/Components/Endpoints/src/Discovery/ComponentApplicationBuilder.cs
public ComponentApplicationBuilder AddAssembly(Assembly assembly)
{
    ArgumentNullException.ThrowIfNull(assembly);
    AddLibrary(assembly.FullName!, _typeInfoResolver.GetRequiredTypeInfos(assembly));
    return this;
}

Server roots are the lifetime equivalence class. Initial marker deserialization resolves and stores ComponentTypeInfo; dynamic add/update operations pass that object into the renderer; resumed circuits retain (typeInfo, parameters) rather than discarding metadata and resolving by Type again.

// src/Components/Shared/src/WebRootComponentManager.cs
public Task AddRootComponentAsync(
    int ssrComponentId,
    [DynamicallyAccessedMembers(Component)] Type componentType,
    ComponentMarkerKey? key,
    WebRootComponentParameters parameters)
    => AddRootComponentAsync(
        ssrComponentId,
        renderer.ComponentTypeInfoResolver.GetRequiredTypeInfo(componentType),
        key,
        parameters);

PR4's feature application and browser scenarios now consume the Native AOT Components.Testing harness from main (#68289) and the shared MSTest/MTP infrastructure from #67083. The upstream harness owns publish/source-generation plumbing; this PR contributes only its application, ServerFactory fixture/routing integration, and component-metadata scenarios. The separate JitDefault category proves undescribed component, JS method, and JSON payload fallbacks remain compatible.

Outcome

Equivalence class What is covered Validation
Runtime type-info consumers Reflection/generated activation and injection, ordinary/cascading/query/session/TempData parameters, unmatched values, persistence, render modes, resolver ordering/caches/hot reload, activator substitution, and reflection-disabled fail-fast behavior Focused Components: 124 passed
Discovery and endpoint value access Type-info route/endpoint discovery, metadata propagation, descriptor-first Session/TempData getters, reflection compatibility, and missing-metadata failure Focused Endpoints: 35 passed
Server root lifetime Initial markers, dynamic roots, resumed roots, parameter deserialization, circuit persistence, and service registration retain ComponentTypeInfo Focused Server: 123 passed
Application generation Factories, inherited/public/private accessors, attributes, diagnostics, persistent serializer closure, typed JSON/JS serialization, and DynamicallyAccessedMembers bridges Shared MSTest generator Test target: 40 passed
Feature integration The PR4 feature project builds on the upstream Components.Testing harness and exercises generated activation plus default reflection compatibility under JIT MSTest/MTP JIT smoke: 2 passed

Stack note: this PR depends on #68297. Framework-owned component descriptor providers are added by the next stack layer; strict reflection-disabled Native AOT proof follows after that.

@javiercn
javiercn force-pushed the javiercn-component-metadata-aot branch from a0b2f1b to 73ba69c Compare August 9, 2026 11:47
@javiercn
javiercn force-pushed the javiercn-component-metadata-aot branch from 73ba69c to cdc58e7 Compare August 9, 2026 12:36
@javiercn
javiercn force-pushed the javiercn-component-metadata-aot branch from cdc58e7 to 1afdfb0 Compare August 9, 2026 19:26
@javiercn
javiercn force-pushed the javiercn-component-metadata-aot branch from c98e962 to eba8437 Compare August 9, 2026 21:47
@javiercn

javiercn commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).
3 pipeline(s) were filtered out due to trigger conditions.

javiercn and others added 16 commits August 11, 2026 12:28
…pe info

Route component activation, injection, parameters, cascades, and persistent members through a shared reflection-backed type-info model. Keep the descriptor implementation internal until generated metadata is introduced.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Verify reflection-backed activation, injection, ordinary parameters, cascading parameters, and persistent members before generated metadata is introduced.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Carry component type info through route discovery, route cache identity, endpoint metadata, and page descriptors. Preserve dynamically accessed type flow and include the donor's final route-table annotation fix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Delete the assembly-scan builder graph now that route and endpoint discovery resolve component type info directly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover route-table metadata, endpoint discovery, hot reload, and page descriptor propagation using normalized component type info with reflection fallback.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Carry resolved component type info through initial markers, dynamic root operations, resumed circuits, renderer attachment, and parameter deserialization.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover metadata-aware root component deserialization, renderer attachment, dynamic operations, circuit persistence, and Server service registration.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Expose component, parameter, and injectable descriptors when the existing metadata context first carries Components. Generate application component factories, attributes, accessors, and diagnostics, then resolve generated type info before the default reflection fallback without adding framework provider machinery.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover component activation metadata, parameters, cascading and persistent members, injection, inherited metadata, accessibility diagnostics, unique contexts, and runtime registration while preserving lower-layer JSON, JS, and binding contexts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Wire the E2E manifest task, add the native-capable source-generated harness and targets, and introduce runnable JIT feature applications with metadata registration and browser fixtures. Native testing remains available to later layers but is not enabled here.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Drive activation, injection, parameters, cascades, binding graphs, JS dispatch, JSON composition, custom events, persistence, Session, protected storage, dynamic roots, and endpoint metadata through the JIT feature application.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Keep a dedicated JIT witness for undescribed components, JS-invokable methods, and JSON payloads using the default reflection fallbacks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Route ComponentBase parameter assignment through renderer-scoped type info and add the compatibility-preserving reflection switch infrastructure and fail-fast branches required when a later layer disables reflection.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Resolve Session and TempData component getters from component parameter descriptors first, preserving reflection fallback compatibility and eliminating their independent reflection caches.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover cascading state, activation, injection, resolver ordering and caches, ParameterView, persistent members, generated render modes, activator substitution, and reflection-disabled fail-fast behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Verify descriptor precedence, reflection compatibility, reflection-disabled failure, and Session and TempData subscription parity.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Verify persistent-state serializer closure, JSON resolver composition, typed JS serialization contracts, and generated parameter bridges for DynamicallyAccessedMembers annotations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@javiercn
javiercn force-pushed the javiercn-component-metadata-aot branch from eba8437 to 103b080 Compare August 11, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant