Skip to content

feat: [WIP] make IgniteUI.Blazor.Lite Native-AOT clean - #390

Draft
damyanpetev wants to merge 3 commits into
masterfrom
dpetev/aot
Draft

feat: [WIP] make IgniteUI.Blazor.Lite Native-AOT clean#390
damyanpetev wants to merge 3 commits into
masterfrom
dpetev/aot

Conversation

@damyanpetev

@damyanpetev damyanpetev commented Sep 1, 2026

Copy link
Copy Markdown
Member

feat: make IgniteUI.Blazor.Lite Native-AOT clean

What & why

Enables the AOT and single-file analyzers (EnableAotAnalyzer/EnableSingleFileAnalyzer, errors via .editorconfig) and makes the library genuinely AOT-safe. The public IsAotCompatible flag is deliberately deferred: Blazor itself is not AOT-compatible yet (Microsoft.AspNetCore.Components ships IsTrimmable only; dotnet/aspnetcore#51598 is open/Backlog), so the claim would outrun the platform — a csproj comment marks the flip point. The AOT analyzer surfaced 14 unique IL3050 sites; 11 are fixed for real, 3 carry narrow justified suppressions. Follow-up to the trimming PR, same methodology: empirical inventory → real fixes where possible → suppressions only with verified invariants → publish-time + runtime gates.

The design rests on verified RDC semantics: Expression...Compile() is NOT RequiresDynamicCode — expressions run interpreted under ILC (documented, slower-not-broken). The IL3050s come from delegate-type construction (Expression.GetFuncType, the non-generic Lambda(body, params) overloads) and from MakeGenericType/MakeGenericMethod. The .NET 9+ analyzer intrinsics recognize statically-visible and class-constrained instantiations.

Real fixes (11 sites, no suppressions)

  • Schema getter builders (7 sites, JsonDataSourceSchema.cs): untyped builders now use Expression.Lambda<Func<object,object>> (statically-known delegate type — the un-annotated overload); typed builders select their delegate type from a new closed typeof(Func<object,T>) map covering exactly the shapes UnmarshalledDataSource.CreateColumn hard-casts to (10 primitives + 9 nullables; enums map via their underlying type, as the expression tree already converted). Types outside the set fall back to the untyped getter — their columns (ObjectValue/arrays) only ever consume the untyped one. Every value-type Func<object,T> instantiation is now a statically visible literal, so ILC pregenerates them.
  • Dynamic content (2 sites, BaseRendererControl.BuildDynamicContentInfo + DynamicContentInfo<T>/IgbTemplateContent<T>): where T : class constraint added (the input set is a verified closed set of 5 reference types via internal UpdateTemplate typeof literals) — the net9+ analyzer proves MakeGenericType safe via the constraint; the factory lambda switched to Expression.Lambda<Func<DynamicContentInfo>>.
  • RuntimeHelper (2 of 4 sites): the probe's two lambdas switched to generic Lambda<TDelegate> overloads (delegate types were already statically written at the cast sites).

Suppressions (3, each with a verified invariant)

Site Code Why safe
BaseRendererControl.BuildDynamicContentInfo IL3050 (net8 analyzer only — no intrinsics there) Closed set of class-constrained reference-type arguments; reference-type instantiations use AOT shared generics. net9+ proves it via the constraint.
RuntimeHelper ctor IL3050 (net8 only — see below) net8 Blazor WASM-only InvokeUnmarshalled probe; no NativeAOT target exists for net8 wasm, and Mono AOT retains the interpreter.
BaseRendererControl.SetPropertyValue IL3050 (Array.CreateInstance) Creates the property's own array PropertyType, present in metadata whenever the property exists.

Structural changes

  • RuntimeHelper gated to #if NET8_0: the probed InvokeUnmarshalled API was removed in net9 (binary-verified), so the probe, its DynamicDependency, and all its trim/AOT suppressions now compile only on net8; net9/net10 always use the raw-pointer InvokeVoid path (already the runtime behavior there). This also removes the latent IL2035 exposure found in the suppression audit — the string DynamicDependency targeting Microsoft.AspNetCore.Components.WebAssembly, an assembly absent in MAUI BlazorWebView publishes, no longer exists on the TFMs those apps use. Dead #if NET5_0 arms folded away (no net5 TFM).
  • Two pre-existing bugs fixed in passing (both inside the rewritten lines, explicitly plan-approved): Commit() allocated TypedFieldGetters as Func<object,object>[] — storing any Func<object,TField> threw ArrayTypeMismatchException (typed-field path was broken for every data type with public non-object fields); SendUnmarshalledColumnDataIntentsMessage null-checked one delegate field but invoked the other.

Regression guards

  • .editorconfig: dotnet_analyzer_diagnostic.category-AOT.severity = error joins the Trimming/SingleFile rules — new IL3xxx fails the build.
  • New tests/IgniteUI.Blazor.Lite.AotSmoke: console app publishing the library under real ILC (PublishAot, IlcTreatWarningsAsErrors — the property ILLinkTreatWarningsAsErrors never reaches ILC) and running 38 asserted checks over the RDC-adjacent paths (all getter shapes incl. enum/nullable/field, dictionary schema, ExtractSchema, IgbJsonContext round-trip; success = exit 100, aspnetcore trimming-test convention). CI publishes and runs it (linux-x64, ~1-2 min). Two things were tried and dropped for the same reason — aspnetcore Components internals are not AOT-clean ([Blazor] Enable Blazor apps running inside of ASP.NET Core to be Native AoT compiled dotnet/aspnetcore#51598) and emit unfixable IL2072s once reached: whole-library TrimmerRootAssembly rooting, and constructing any ComponentBase-derived type in Main (the MarshalByValueFactory check — a static switch of news with no dynamic-code surface, behaviorally covered by the trimming browser fixture). Analysis covers what Main reaches, which is the library's entire RDC surface. Locally without the C++ toolchain: dotnet run -p:SimulateNoDynamicCode=true forces the interpreter paths via the documented DynamicCodeSupport feature switch.
  • Empirical finding from the gate's first CI run: class-level [DynamicallyAccessedMembers] on an instantiated data-item type is honored by wasm ILLink but not by ILC (the smoke POCO lost its reflected properties through the unannotated Type flow). docs/TRIMMING.md consumer guidance now says to prefer the flow-independent [DynamicDependency] form, and the smoke app exercises exactly that pattern.
  • New manual Wasm AOT Smoke workflow (workflow_dispatch): wasm RunAOTCompilation=true publish + the existing TrimmedPublish browser fixture against that output — validates the actual Blazor wasm-AOT consumer scenario (Mono AOT emits no IL3xxx; that's the ILC smoke's job). To be dropped if it never catches anything the other gates don't.
  • docs/TRIMMING.md gains a "Maintaining AOT compatibility" contributor section + rewritten consumer-facing Native AOT section; the .agents trimming skill is now trimming & AOT.

Verification

  • dotnet build, all 3 TFMs: 0 IL2xxx + 0 IL3xxx with all category gates as errors.
  • dotnet test: 965 passed / 0 failed on each of net8.0/net9.0/net10.0.
  • Trimmed publish of PublishSmoke: clean; TrimmedPublish browser fixture 5/5 (the combo data fact runs through the rewritten getters against a real trimmed publish).
  • AotSmoke: 38/38 checks on CoreCLR and with IsDynamicCodeSupported=false (interpreter-forced). The full ILC publish+run gate executes in CI (this machine lacks the C++ linker — managed side verified locally, native leg is CI's).

Behavioral notes

  • where T : class on DynamicContentInfo<T>/IgbTemplateContent<T> is technically API-narrowing, but the only instantiation channel (internal UpdateTemplate) has always passed reference types only.
  • The AotSmoke runtime checks are data-source-scoped by design — that's where the library's entire RequiresDynamicCode surface lives; components carry no dynamic code, and no supported scenario runs Blazor components under ILC today. The whole-library TrimmerRootAssembly rooting is what gates component code at ILC-analysis level.
  • Typed getters for member types outside the closed set (e.g. Guid, exotic enum underlyings) now return the untyped getter instead of an unconsumed typed delegate — those columns only ever read the untyped getter, so behavior is unchanged.
  • Known latent bug documented, not fixed here (pre-existing): Nullable<enum> members resolve to Nullable<int>-style columns whose CreateColumn cast has always thrown; tracked in the followups plan.

🤖 Generated with Claude Code


// The closed-set typed getters — the exact casts UnmarshalledDataSource.CreateColumn performs.
Check(((Func<object, int>)Typed("Id"))(item) == 42, "typed int getter");
Check(((Func<object, double>)Typed("Ratio"))(item) == 2.5, "typed double getter");
var dictSchema = JSDataSourceSchema.CreateFromDictionary(dict);
Check(((Func<object, int>)dictSchema.TypedPropertyGetters[IndexOf(dictSchema.PropertyNames, "n")])(dict) == 5, "typed dictionary int getter");
Check(((Func<object, string>)dictSchema.TypedPropertyGetters[IndexOf(dictSchema.PropertyNames, "s")])(dict) == "text", "typed dictionary string getter");
Check((double)dictSchema.PropertyGetters[IndexOf(dictSchema.PropertyNames, "d")](dict) == 1.25, "untyped dictionary getter");
Comment on lines +117 to +121
catch (Exception ex)
{
Console.Error.WriteLine("AOT smoke: " + ex);
return 1;
}
Base automatically changed from dpetev/minor-attr-inspection-refactor to master September 1, 2026 16:32
damyanpetev and others added 3 commits September 3, 2026 18:50
- IlcTreatWarningsAsErrors instead of ILLinkTreatWarningsAsErrors, which never reaches ILC (Microsoft.NETCore.Native.targets defaults it from TreatWarningsAsErrors)
- drop whole-library TrimmerRootAssembly: rooting pulls aspnetcore Components internals (not AOT-clean, dotnet/aspnetcore#51598) into ILC analysis with unfixable IL2072s; Main-reachable analysis covers the library's entire RequiresDynamicCode surface
- preserve the smoke POCO via DynamicDependency: the gate's first CI run proved class-level DynamicallyAccessedMembers is honored by wasm ILLink but NOT by ILC for types reached through unannotated Type flows - docs/TRIMMING.md now tells consumers to prefer the DynamicDependency form
- fix CS8618/CS8602 nullable warnings in the smoke app

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e Main

Constructing any marshal-by-value type makes ILC compile SetParametersAsync and hit
aspnetcore's own IL2072 in ComponentProperties.SetProperties (dotnet/aspnetcore#51598) -
the same framework wall that forced dropping TrimmerRootAssembly, reached via construction
instead of rooting. The factory is a static switch of news with no dynamic-code surface,
behaviorally covered by the trimmed browser fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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