[API Proposal]: Aspire Dashboard AoT framework support
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 281
Description
## Background and Motivation
We want to support ahead-of-time compilation for the **Aspire Dashboard**, and the framework support that requires from Blazor does not exist yet. This proposal is that support.
Blazor discovers a great deal about an application at runtime. Which components exist and how to construct them; which of their members are parameters, and how to read and write each one; which members carry `[Inject]` and what service each wants; which members of a model are reachable through `@bind`; which methods are `[JSInvokable]` and how to dispatch to them; and how to serialize the values flowing through all of the above. Today every one of those questions is answered by walking `MemberInfo` and invoking reflectively, and the JSON carrying the values is serialized through the reflection-based contract resolver.
All of that information is knowable at compile time. The Razor compiler already sees the component graph; the attributes are already there in source. What is missing is a way for a source generator to write that knowledge down, and a way for the framework to use it when it is available.
That is what this API is: **a contract between generated code and the framework, describing what the framework currently discovers by reflection.** When an application supplies it, Blazor reads parameters, resolves injectables, walks bind models, dispatches JS interop and serializes state through ordinary generated code. When it is absent, nothing changes and the existing reflection paths run exactly as before.
The Dashboard is what has driven the shape here — it is a real application that exercises all of these paths at once, which is why we used it to validate the design rather than a synthetic sample. But nothing in this API is specific to it: any Blazor Server application can supply the same metadata, and applications that stay JIT-compiled benefit too, through less runtime discovery, less metadata kept alive for trimming, and serialization that no longer depends on reflection being enabled.
Because generated code lives in the **application's own assembly**, every type and member it touches has to be public. That is why this proposal is as large as it is. Nothing here is meant to be written by hand: an application author adds a single call, `AddComponentMetadata()`, and the generator produces the rest. The types sit in `*.Infrastructure` namespaces, and everything proposed here carries `[Experimental("ASPNETCORE9004")]` so the shape can still move while we take more applications through it.
## Proposed API
### `Microsoft.AspNetCore.Components` — what the generator writes down
```diff
namespace Microsoft.AspNetCore.Components.Infrastructure;
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class ComponentDescriptor
+{
+ public Type Type { get; init; }
+ public Func? CreateInstance { get; init; }
+ public IReadOnlyList Parameters { get; init; }
+ public IReadOnlyList Injectables { get; init; }
+ public IReadOnlyList Metadata { get; init; }
+}
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class ComponentParameterDescriptor
+{
+ public string Name { get; init; }
+ public Type ParameterType { get; init; }
+ public Attribute Attribute { get; init; }
+ public Func GetValue { get; init; }
+ public Action SetValue { get; init; }
+ public Func? GetStateSerializer { get; init; }
+}
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class ComponentInjectableDescriptor
+{
+ public string Name { get; init; }
+ public Type ServiceType { get; init; }
+ public InjectAttribute Attribute { get; init; }
+ public Action SetValue { get; init; }
+}
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class BindableTypeDescriptor
+{
+ public Type Type { get; init; }
+ public IReadOnlyList Members { get; init; }
+ public IReadOnlyList Indexers { get; init; }
+}
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class BindableMemberDescriptor
+{
+ public string Name { get; init; }
+ public Type MemberType { get; init; }
+ public Func GetValue { get; init; }
+}
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class BindableIndexerDescriptor
+{
+ public Type IndexType { get; init; }
+ public Type ValueType { get; init; }
+ public Func GetValue { get; init; }
+}
```
### `Microsoft.JSInterop` — described `[JSInvokable]` dispatch
```diff
namespace Microsoft.JSInterop.Infrastructure;
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public sealed class JSInvokableMethodDescriptor
+{
+ public string Identifier { get; init; }
+ public string AssemblyName { get; init; }
+ public Type TargetType { get; init; }
+ public Func> Invoke { get; init; }
+}
namespace Microsoft.JSInterop;
public abstract class JSRuntime
{
+ [Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+ protected virtual IReadOnlyList? InvokableMethods { get; }
}
```
### `Microsoft.AspNetCore.Components.Web` — the aggregate and its registration
```diff
namespace Microsoft.AspNetCore.Components.Web;
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public abstract class RazorComponentsMetadataContext
+{
+ public abstract IReadOnlyList Components { get; }
+ public abstract IReadOnlyList BindableTypes { get; }
+ public abstract IReadOnlyList JSInvokableMethods { get; }
+ public abstract IJsonTypeInfoResolver? JsonTypeInfoResolver { get; }
+}
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
+public sealed class BindableModelAttribute : Attribute
+{
+ public Type ModelType { get; init; }
+}
namespace Microsoft.Extensions.DependencyInjection;
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public static class ComponentMetadataServiceCollectionExtensions
+{
+ public static IServiceCollection AddComponentMetadata(this IServiceCollection services)
+ where TContext : RazorComponentsMetadataContext, new();
+}
```
### `Microsoft.AspNetCore.Components.Server` — describing how a type is stored
The one piece here that is written by hand rather than generated.
```diff
namespace Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
+[Experimental("ASPNETCORE9004", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public abstract class ProtectedBrowserStorageSerializer
+{
+ protected ProtectedBrowserStorageSerializer();
+ public abstract string Serialize(T value);
+ public abstract T Deserialize(string data);
+}
public abstract class ProtectedBrowserStorage
{
+ public ValueTask SetAsync(string key, TValue value);
+ public ValueTask SetAsync(string purpose, string key, TValue value);
}
```
The `SetAsync` overloads are deliberately **not** experimental. Overload resolution prefers them over the existing `SetAsync(string, object)` whenever the argument's static type is more specific than `object`, so marking them would raise a diagnostic on existing, unmodified call sites that never opt into a serializer. With no serializer registered they behave exactly as the object-typed overloads do; opting in requires touching `ProtectedBrowserStorageSerializer`, which is where the diagnostic belongs.
## Usage Examples
**What an application author writes.** One line, plus a `[BindableModel]` assembly attribute for any model reached through `@bind` that the generator cannot infer from the component graph alone:
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddComponentMetadata(); // AppMetadata is generated
```
**What the generator emits** (abbreviated — this is the shape the contract has to support):
```csharp
internal sealed partial class AppMetadata : RazorComponentsMetadataContext
{
public override IReadOnlyList Components { get; } =
[
new ComponentDescriptor
{
Type = typeof(Counter),
CreateInstance = static _ => new Counter(),
Parameters =
[
new ComponentParameterDescriptor
{
Name = "IncrementAmount",
ParameterType = typeof(int),
Attribute = new ParameterAttribute(),
GetValue = static c => ((Counter)c).IncrementAmount,
SetValue = static (c, v) => ((Counter)c).IncrementAmount = (int)v!,
},
],
Injectables = [ /* ... */ ],
Metadata = [ /* ... */ ],
},
];
public override IReadOnlyList JSInvokableMethods { get; } =
[
new JSInvokableMethodDescriptor
{
Identifier = "NotifyResize",
AssemblyName = "Dashboard",
TargetType = typeof(ResizeListener),
Invoke = static (target, json, options) => /* generated deserialize + call */,
},
];
public override IJsonTypeInfoResolver? JsonTypeInfoResolver => AppJsonContext.Default;
}
```
**The hand-written piece.** A type stored in protected local storage that has no JSON contract:
```csharp
public sealed class ThemeSerializer : ProtectedBrowserStorageSerializer
{
public override string Serialize(Theme value) => value.Name;
public override Theme Deserialize(string data) => new(data);
}
```
```csharp
builder.Services.AddSingleton, ThemeSerializer>();
```
```csharp
@inject ProtectedLocalStorage Storage
await Storage.SetAsync("theme", new Theme("solarized"));
var result = await Storage.GetAsync("theme");
```
## Alternative Designs
N/A
## Risks
N/A
Contributor guide
Research direction
Start by reviewing the proposed ComponentDescriptor, RazorComponentsMetadataContext, AddComponentMetadata, and ProtectedBrowserStorageSerializer APIs across their listed namespaces. Trace how the generator contract is expected to cover components, binding, JS interop, and serialization; done means the framework can consume generated metadata while retaining the existing reflection fallback.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend-api-design, web-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100