Skip to content

[Blazor] Generate framework component metadata - #68300

Draft
javiercn wants to merge 5 commits into
javiercn-component-metadata-aotfrom
javiercn-framework-component-metadata
Draft

[Blazor] Generate framework component metadata#68300
javiercn wants to merge 5 commits into
javiercn-component-metadata-aotfrom
javiercn-framework-component-metadata

Conversation

@javiercn

@javiercn javiercn commented Aug 9, 2026

Copy link
Copy Markdown
Member

Overview

This is layer 5 of 6 for the Aspire Dashboard Native AOT feature, stacked on #68299. Across five commits and 23 files, it teaches the Razor components metadata generator to import component descriptions from the framework assemblies that own those components, then adds providers for Components, Web, Forms, Endpoints, Authorization, QuickGrid, Media, and WebAssembly Authentication. The governing constraint is that application metadata and framework-owner metadata must compose without reflection, while optional assemblies contribute nothing unless the application actually references them; strict reflection switches and Native AOT execution remain in layer 6.

Design

There is no new public API. The generator emits an internal, compile-time-only attribute that lets the feature application name closed generic roots which cannot be inferred from an open generic definition:

// src/Components/Endpoints/gen/RazorComponentsMetadataGenerator.cs
// Internal to the generated compilation: applications do not acquire a new supported API surface.
// AllowMultiple lets one metadata context root every required closed generic family.
context.RegisterPostInitializationOutput(static output =>
    output.AddSource(
        "ComponentTypeInfoAttribute.g.cs",
        """
        namespace Microsoft.AspNetCore.Components.Web
        {
            [global::System.AttributeUsage(global::System.AttributeTargets.Class, AllowMultiple = true)]
            internal sealed class ComponentTypeInfoAttribute : global::System.Attribute
            {
                public ComponentTypeInfoAttribute(global::System.Type componentType)
                {
                }
            }
        }
        """));

Each owner assembly implements the same internal convention: a type named Microsoft.AspNetCore.Components.Infrastructure.BuiltInComponentDescriptors exposes GetDescriptors() for fixed components and named generic factory methods for closed families. The application generator deliberately does not duplicate framework internals: owner providers can access private members, required constructors, and internal helper component graphs safely, while the generator carries only the data required to bind to those factories.

// src/Components/Endpoints/gen/Models/MetadataContextModel.cs
// The incremental model keeps assembly/factory identity, concrete type arguments, CLR constraints,
// and DynamicallyAccessedMembers values as immutable data rather than retaining Roslyn symbols.
internal sealed record class BuiltInDescriptorFactoryModel(
    string AssemblyName,
    string MethodName,
    ImmutableArray<string> TypeArgumentFullyQualifiedNames,
    ImmutableArray<string> TypeParameterConstraintClauses,
    ImmutableArray<int> TypeParameterDynamicallyAccessedMemberValues);

This owner-provider design was chosen over application-side reflection or re-describing framework private state. It also preserves package isolation: the known provider list is filtered against the host compilation's referenced assemblies, so Authorization, QuickGrid, Media, and WebAssembly Authentication metadata is imported only when those packages are present. Framework JS callback providers are unchanged and remain owned by the earlier JSInterop layer.

Implementation

The generator starts from the metadata context a consumer already registers. It computes application descriptors once, discovers referenced owner providers, collects implicit and explicitly rooted generic factories, and merges those inputs into the context model:

// src/Components/Endpoints/gen/RazorComponentsMetadataGenerator.cs
var components = CollectComponents(expanded, types, diagnostics, cancellationToken);
var implicitBuiltInDescriptorFactories = CollectImplicitBuiltInDescriptorFactories(
    expanded,
    types,
    cancellationToken);
var referencedAssemblyNames = new HashSet<string>(
    expanded.SourceModule.ReferencedAssemblySymbols.Select(static assembly => assembly.Identity.Name),
    StringComparer.Ordinal);

// Optional-package isolation: if QuickGrid is not referenced, no QuickGrid provider accessor is emitted.
var builtInDescriptorAssemblies = BuiltInDescriptorAssemblies
    .Where(referencedAssemblyNames.Contains)
    .ToImmutableArray();

var explicitComponents = CollectExplicitComponents(
    contextType,
    expanded,
    types,
    diagnostics,
    cancellationToken);

models.Add(new MetadataContextModel(
    Namespace: contextType.ContainingNamespace is { IsGlobalNamespace: false } ns ? ns.ToDisplayString() : null,
    ContainingTypes: GetContainingTypeNames(contextType),
    TypeName: contextType.Name,
    TypeKeyword: contextType.IsRecord ? "record" : "class",
    DeclaresJsonTypeInfoResolver: DeclaresMember(contextType, "JsonTypeInfoResolver"),
    BuiltInDescriptorAssemblies: builtInDescriptorAssemblies,
    BuiltInJSInvokableDescriptorAssemblies: builtInJSInvokableDescriptorAssemblies,
    BuiltInDescriptorFactories: implicitBuiltInDescriptorFactories.AddRange(explicitComponents.Factories),
    Components: components.AddRange(explicitComponents.Components),
    BindableTypes: bindableTypes,
    JSInvokableMethods: jsInvokableMethods));

Emission preserves the lower layer's application descriptors and spreads owner descriptors after them. Both fixed providers and generic factories are linked with generated UnsafeAccessor declarations, so the runtime receives ordinary descriptor arrays with no assembly scanning, MakeGenericType, or reflective member access:

// src/Components/Endpoints/gen/Emitters/RazorComponentsMetadataGenerator.Emitter.cs
writer.WriteLine($"internal static readonly {ComponentDescriptorType}[] Components =");
writer.OpenBracket();

for (var i = 0; i < components.Length; i++)
{
    EmitComponent(writer, components[i], i);
}

for (var i = 0; i < builtInDescriptorAssemblies.Length; i++)
{
    writer.WriteLine($".. GetBuiltInComponentDescriptors_{i}(null),");
}

for (var i = 0; i < builtInDescriptorFactories.Length; i++)
{
    var typeArguments = string.Join(", ", builtInDescriptorFactories[i].TypeArgumentFullyQualifiedNames);
    writer.WriteLine($".. GetBuiltInComponentDescriptorFactory_{i}<{typeArguments}>(null),");
}

writer.CloseBracketWithSemicolon();

// The emitted declaration names the internal provider by assembly-qualified type rather than reflecting.
for (var i = 0; i < builtInDescriptorAssemblies.Length; i++)
{
    writer.WriteLine();
    writer.WriteLine(
        "[global::System.Runtime.CompilerServices.UnsafeAccessor(" +
        "global::System.Runtime.CompilerServices.UnsafeAccessorKind.StaticMethod, Name = \"GetDescriptors\")]");
    writer.WriteLine(
        $"private static extern {ComponentDescriptorType}[] GetBuiltInComponentDescriptors_{i}(" +
        "[global::System.Runtime.CompilerServices.UnsafeAccessorType(" +
        $"{SymbolHelpers.ToStringLiteral($"{BuiltInDescriptorProviderType}, {builtInDescriptorAssemblies[i]}")})] object? target);");
}

A fixed owner inventory is represented by Components' Router: the provider owns activation, every parameter, and injections whose setters are private. Web, Forms, and Endpoints use the same pattern for their non-generic inventories.

// src/Components/Components/src/Infrastructure/BuiltInComponentDescriptors.cs
new ComponentDescriptor
{
    Type = typeof(Router),
    CreateInstance = static _ => new Router(),
    Parameters =
    [
        CreateParameter<Assembly>(
            nameof(Router.AppAssembly),
            static target => target.AppAssembly,
            static (target, value) => target.AppAssembly = value),
        // AdditionalAssemblies, NotFound, Found, navigation callbacks, and layout metadata omitted.
    ],
    Injectables =
    [
        CreateInjectable<NavigationManager>("NavigationManager", SetNavigationManager),
        CreateInjectable<INavigationInterception>("NavigationInterception", SetNavigationInterception),
        CreateInjectable<ILoggerFactory>("LoggerFactory", SetLoggerFactory),
        CreateInjectable<IServiceProvider>("ServiceProvider", SetServiceProvider),
    ],
};

[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_NavigationManager")]
private static extern void SetNavigationManager(Router target, NavigationManager value);

The remaining provider behaviors form four equivalence classes; one representative and each delta are shown here:

  • Closed generic factories: Forms inputs, ValidationMessage<T>, Virtualize<T>, OwningComponentBase, QuickGrid families, and RemoteAuthenticatorViewCore<TState> preserve concrete arguments, constraints, and trimming annotations. QuickGrid additionally contributes the internal cascading context and tuple-shaped virtualization helper needed by QuickGrid<T>:
// src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/BuiltInComponentDescriptors.cs
internal static ComponentDescriptor[] CreateQuickGridDescriptors<TGridItem>()
    =>
    [
        new ComponentDescriptor
        {
            Type = typeof(QuickGrid<TGridItem>),
            CreateInstance = static _ => new QuickGrid<TGridItem>(),
            Injectables =
            [
                CreateInjectable<QuickGrid<TGridItem>, IServiceProvider>(
                    "Services",
                    QuickGridAccessors<TGridItem>.SetServices),
                CreateInjectable<QuickGrid<TGridItem>, IJSRuntime>(
                    "JS",
                    QuickGridAccessors<TGridItem>.SetJS),
                CreateInjectable<QuickGrid<TGridItem>, NavigationManager>(
                    "NavigationManager",
                    QuickGridAccessors<TGridItem>.SetNavigationManager),
            ],
        },
        new ComponentDescriptor
        {
            Type = typeof(CascadingValue<InternalGridContext<TGridItem>>),
            // ChildContent, Value, Name, and IsFixed parameter descriptors omitted.
        },
        .. VirtualizeDescriptorFactory.CreateDescriptors<(int RowIndex, TGridItem Data)>(null),
    ];

internal static ComponentDescriptor[] CreateColumnBaseDescriptors<
    TGridItem,
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TColumn>()
    where TColumn : ColumnBase<TGridItem>
    =>
    [
        new ComponentDescriptor
        {
            Type = typeof(TColumn),
            Parameters =
            [
                CreateCascadingParameter<ColumnBase<TGridItem>, InternalGridContext<TGridItem>>(
                    "InternalGridContext",
                    ColumnBaseAccessors<TGridItem>.GetInternalGridContext,
                    ColumnBaseAccessors<TGridItem>.SetInternalGridContext),
            ],
        },
    ];
  • Private/internal component state: Authorization describes AuthorizeView, AuthorizeRouteView, CascadingAuthenticationState, and the private nested AuthorizeRouteViewCore; owner-side helpers construct the private core while UnsafeAccessor bridges its inherited private parameters and services. QuickGrid and WebAssembly Authentication use the same bridge pattern for private injections.
  • Required construction: Media's Image, Video, and FileDownload cannot use a plain parameterless factory because Source is required. Their owner provider deliberately constructs new Image { Source = null! } (and peers) before normal parameter assignment and supplies the private JS runtime/logger injections.
  • Remote-auth family: RemoteAuthenticatorViewCore<TAuthenticationState> preserves where TAuthenticationState : RemoteAuthenticationState plus the JsonSerialized DAM requirement, and closes all five service types over the same authentication-state type.

The feature application roots representative closed instances beside their optional assembly references, covering generic Forms inputs, virtualization, application generics, QuickGrid's grid/column/notifier graph, and remote authentication:

// src/Components/test/testassets/BlazorAotFeatures/BlazorServerAotSample/SampleMetadata.cs
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.CascadingValue<string>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.Forms.ValidationMessage<string>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize<string>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.Forms.InputDate<DateTime>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.Forms.InputNumber<int>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.QuickGrid.QuickGrid<string>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.QuickGrid.PropertyColumn<string, string>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.QuickGrid.TemplateColumn<string>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.QuickGrid.Infrastructure.ColumnsCollectedNotifier<string>))]
[ComponentTypeInfo(typeof(global::Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticatorViewCore<global::Microsoft.AspNetCore.Components.WebAssembly.Authentication.RemoteAuthenticationState>))]
internal sealed partial class SampleMetadata : RazorComponentsMetadataContext
{
    // Existing JSON resolver composition is unchanged.
}

Outcome

Equivalence class What the tests prove
Provider selection and composition Only referenced owner assemblies emit accessors; application descriptors remain isolated and compose with owner descriptors; JS callback providers are not part of this change.
Fixed framework inventories Components, Web, Forms, Endpoints, and Authorization expose the exact component/parameter/injection shapes, including route/layout metadata and hidden member round-trips.
Generic factories Forms, virtualization, owning components, QuickGrid, derived custom columns, and remote-auth state families preserve concrete type arguments, constraints, DAM annotations, activation, cascading parameters, and private injections.
Owner-specific deviations Media's required construction succeeds; Authorization's private nested core is constructible; QuickGrid emits its internal helper graph; remote authentication closes all services over a custom state type.
Feature roots The existing feature application compiles explicit representatives for every generic family and carries optional-package references beside their providers.

Validation: the complete built-in descriptor generator matrix passed 113/113 tests. Fast affected suites across Components, Web, Forms, Endpoints, Authorization, QuickGrid, Media, and WebAssembly Authentication passed 2,784 tests with 8 existing skips. Native AOT execution and strict reflection-disabled validation are intentionally deferred to layer 6.

@javiercn
javiercn force-pushed the javiercn-framework-component-metadata branch from c68b966 to d9a8361 Compare August 9, 2026 11:58
@javiercn
javiercn force-pushed the javiercn-framework-component-metadata branch from d9a8361 to 27ac444 Compare August 9, 2026 12:46
@javiercn
javiercn force-pushed the javiercn-framework-component-metadata branch from 27ac444 to 617eaa6 Compare August 9, 2026 19:34
javiercn and others added 5 commits August 9, 2026 23:48
Discover framework-owned component assemblies and merge their owner descriptors with application metadata. Add generic factory imports and generated UnsafeAccessors for Components, Web, Forms, and Endpoints.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover core provider imports, explicit closed generic roots, owner factory selection, and provider-aware generator diagnostics while retaining application descriptor isolation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add owner descriptors for Authorization, QuickGrid, Media, and WebAssembly Authentication, including constrained generic factories, private injection bridges, and required construction paths.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Exercise the complete built-in component descriptor matrix, including constrained QuickGrid factories, private injectable bridges, Media construction, and remote authentication component families. Framework JS callback coverage remains in its lower stack layer.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reference optional framework packages beside their owner providers and root representative closed generic components for Forms, virtualization, QuickGrid, and remote authentication in the existing JIT feature harness.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@javiercn
javiercn force-pushed the javiercn-framework-component-metadata branch from 617eaa6 to 58a0cfe Compare August 9, 2026 21:54
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