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. Across 17 commits, it gives component activation, member access, routing/discovery, and Server root operations one shared ComponentTypeInfo model, then teaches the existing application metadata generator to produce that model at compile time. The cross-cutting constraint is generated first, reflection last: applications that register metadata avoid the reflection paths, while existing applications with no metadata context continue to work unchanged; framework-owned descriptor providers and strict Native AOT enablement remain in layers 5 and 6 respectively.

Design

The new experimental contract describes a component as executable metadata rather than as reflected members. The descriptor carries construction, parameters, injection, and attribute-shaped facts in the same forms the runtime already consumes:

// src/Components/Components/src/Infrastructure/ComponentDescriptor.cs
// The generator emits one of these for each completely describable application component.
[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
public sealed class ComponentDescriptor
{
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
    public required Type Type { get; init; }

    // null means the runtime may use its compatibility activation path when reflection is enabled.
    public Func<IServiceProvider, IComponent>? CreateInstance { get; init; }

    public IReadOnlyList<ComponentParameterDescriptor> Parameters { get; init; } = [];
    public IReadOnlyList<ComponentInjectableDescriptor> Injectables { get; init; } = [];

    // RouteAttribute, RenderModeAttribute, LayoutAttribute, endpoint attributes, and future metadata
    // remain an open attribute bag instead of becoming one API property per framework feature.
    public IReadOnlyList<object> Metadata { get; init; } = [];
}

ComponentParameterDescriptor adds typed getter/setter delegates plus an optional closed persistent-state serializer factory. ComponentInjectableDescriptor is intentionally separate because injection has different semantics: service type, keyed [Inject] metadata, and a setter, but no getter or unmatched-value behavior. Both shapes let generated code use direct assignments or UnsafeAccessor bridges without runtime generic construction or reflection.

The existing application context gains one new capability, and the existing registration call composes it with the JSON, JS-invokable, and bindable metadata introduced by lower layers:

// 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);
        }
    });

    // The same registration still contributes lower-layer JSON/binding metadata.
    services.AddSingleton<RazorComponentsMetadataContext>(context);
    services.TryAddSingleton<IComponentMetadataResolver>(
        static services => services.GetRequiredService<ComponentMetadataResolver>());
    services.TryAddSingleton<IComponentTypeInfoResolver>(ComponentTypeInfoResolverFactory.Create);
    return services;
}

Two design decisions keep this backward-compatible and reviewable:

  • A component is generated completely or not at all. A partial descriptor would be unsafe because the runtime trusts its member lists and could silently skip a parameter or injected property. An inaccessible or unsupported application component instead receives a diagnostic and remains on the unchanged reflection path.
  • The component reflection switch belongs to the runtime model, but defaults to true. This layer defines generated-only failure semantics; layer 6 is responsible for actually setting the switch to false in the strict Native AOT lane.
// src/Components/Components/src/ComponentTypeInfoResolverFactory.cs
internal static class ComponentMetadataFeature
{
    internal const string SwitchName =
        "Microsoft.AspNetCore.Components.ComponentMetadata.IsReflectionEnabledByDefault";

    [FeatureSwitchDefinition(SwitchName)]
    [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))]
    [FeatureGuard(typeof(RequiresDynamicCodeAttribute))]
    internal static bool IsReflectionEnabledByDefault { get; } =
        AppContext.TryGetSwitch(SwitchName, out var value) ? value : true;
}

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-owned descriptor lists/factories are deliberately absent here. The application generator skips framework assemblies; layer 5 supplies owner-authored framework metadata that can later merge with application descriptors through the resolver model established here.

Implementation

A renderer creates one resolver chain and passes it to activation, property injection, parameter binding, cascading state, persistence, discovery, and roots. The source-generated resolver is consulted first; the composite caches positive type/name/assembly lookups, fills a missing generated factory from a later resolver, and invalidates caches for hot reload. When 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 refuses partial descriptions, preserves most-derived member semantics, reconstructs component attributes, and emits executable delegates. The representative parameter emission below covers ordinary, cascading/query/session/TempData, and persistent parameters; their only deltas are the reconstructed attribute and optional serializer/accessor bridge.

// src/Components/Endpoints/gen/RazorComponentsMetadataGenerator.Components.cs
// A partial descriptor is never emitted: any unreachable required member returns false for the
// whole component, leaving the compatibility resolver responsible for it.
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;
}
// src/Components/Endpoints/gen/Emitters/RazorComponentsMetadataGenerator.Emitter.cs
// Representative generated parameter descriptor. Direct access is used when legal; inaccessible
// or DynamicallyAccessedMembers-annotated members use generated accessors with narrow suppressions.
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)
{
    // The declared T is known at compile time, so no MakeGenericType is needed at runtime.
    writer.WriteLine(
        $"GetStateSerializer = static __services => __services.GetService(" +
        $"typeof({StateSerializerType}<{parameter.PropertyTypeFullyQualifiedName}>)),");
}

At runtime, component entry points carry renderer-scoped type info instead of rediscovering members independently. ComponentBase binds through the renderer's resolver; ComponentFactory reads render mode metadata, uses generated activation and injection delegates, and only permits legacy type-based custom activators when reflection is enabled. Session and TempData use the same descriptor getter rather than maintaining separate 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 now enumerate ComponentTypeInfo rather than scanning exported types independently; route cache identity includes the resolver, and endpoint metadata is the descriptor metadata minus RouteAttribute. Layout and render-mode consumers use the same metadata bag. The obsolete 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;
}

internal RazorComponentApplication Build()
{
    var pages = new List<PageComponentDescriptor>();
    var components = new List<ComponentDescriptor>();

    foreach (var typeInfos in _assemblies.Values)
    {
        foreach (var typeInfo in typeInfos)
        {
            components.Add(typeInfo.Descriptor);
            var routes = typeInfo.Metadata.OfType<RouteAttribute>();
            var endpointMetadata = typeInfo.Metadata
                .Where(static item => item is not RouteAttribute)
                .ToArray();

            foreach (var route in routes)
            {
                pages.Add(new PageComponentDescriptor(
                    route.Template, typeInfo.Type, route.Template, endpointMetadata));
            }
        }
    }

    return new RazorComponentApplication([.. pages], [.. components]);
}

Server roots are the lifetime-equivalence class. Initial marker deserialization resolves and stores ComponentTypeInfo; dynamic add/update operations pass that same object into the renderer; resumed circuits enumerate retained (typeInfo, parameters) pairs. This prevents a generated descriptor from being discarded and re-resolved by type midway through the operation.

// 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);

internal IEnumerable<(int id, ComponentMarkerKey key,
    (ComponentTypeInfo componentTypeInfo, ParameterView parameters))> GetRootComponents()
{
    foreach (var (id, (_, key, typeInfo, parameters)) in _webRootComponents)
    {
        yield return (id, key, (typeInfo, parameters));
    }
}

The feature harness is native-capable but this layer runs it under JIT. An E2E project can opt into NativeTesting; the testing targets then add a build-only source generator to the entry-point application's own compilation, avoiding startup hooks that a native image cannot load. The permanent scenarios cover application-generated component metadata alongside the lower-layer binding, JS, and JSON capabilities; a separate JitDefault test proves undescribed component, JS method, and JSON payload fallbacks still work.

Outcome

Equivalence class What was proven Validation
Runtime type-info consumers Reflection-backed and generated activation, constructor/property injection, ordinary/cascading/query/session/TempData parameters, unmatched values, persistent members, render modes, custom-activator substitution, resolver ordering/caches/hot reload, and reflection-disabled fail-fast behavior Components: 1,332 passed, 8 skipped (1,340 total)
Discovery and endpoint value access Route discovery, resolver-sensitive route caching, endpoint metadata, hot reload, descriptor-first Session/TempData getters, reflection compatibility, and missing-metadata failure Endpoints: 819 passed; focused getter/Session/TempData: 27 passed
Server root lifetime Initial markers, dynamic add/update/remove, resumed roots, parameter deserialization, circuit persistence, and service registration retain ComponentTypeInfo Server: 416 passed
Application generation Activation factories, inherited/public/private accessors, attributes, accessibility diagnostics, persistent serializer closure, typed JSON/JS serialization, and DynamicallyAccessedMembers bridges Generator: 40 passed; focused Web registration: 3 passed
Linking The new contracts and runtime paths remain linkable in general and browser-Wasm graphs LinkabilityChecker and WasmLinker builds passed with 0 warnings
End-to-end behavior Generated activation/inherited members, parameters/cascades/injection, binding graph, JS dispatch, JSON composition, custom events, persistence, Session, protected storage, dynamic roots, endpoint metadata, plus default reflection fallbacks Targeted JIT browser witnesses passed; strict Native AOT execution is intentionally deferred to layer 6

Stack note: this PR depends on #68297. Framework-owned component descriptor providers are added by the next stack layer; the final 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 and others added 17 commits August 9, 2026 23:41
…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 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.

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