Microsoft.UI.Reactor (Reactor)'s core framework targets .NET native AOT. The repo sets IsAotCompatible=true and treats the IL2*/IL3* analyzer warnings as errors on the core library, so trimming/AOT regressions can't merge silently. The tests/stress_perf/StressPerf.Reactor benchmark harness publishes Reactor with PublishAot=true on every CI build that runs perf — that's the canary for the framework's AOT viability.
Most of Reactor's runtime stays AOT-clean by default. Reflection-heavy development surfaces are now gated behind build-time feature switches so release/AOT apps can keep them trimmed unless they explicitly opt in.
This page enumerates what works, what doesn't, and what's planned. If you publish your app with PublishAot=true, you're responsible for staying inside the green column.
- Core reconciler — virtual element tree, diffing, mount/update, keyed reconciliation, element pooling.
- DSL & elements — factory methods, fluent modifiers.
- Hooks —
UseState,UseReducer,UseEffect,UseMemo,UseRef,UseCallback. (See note onUseObservablebelow.) - Flex layout — Yoga port is pure C#, no reflection.
- Charting (D3) — algorithm port is pure C#.
- Markdown — md4c-backed parser and renderer.
- Commanding — command records, focus-scoped accelerators.
- Animation — compositor-layer transitions, keyframes.
- Theming —
ThemeReftokens, style caching. Brushes resolved throughXamlControlsResourceswork under AOT once the WindowsAppSDK#6394 publish workaround is in place (see Required publish-time workarounds below). - Built-in WinUI controls —
NavigationView,TabView,Pivot,TreeView,Expander,InfoBar,MenuBar,BreadcrumbBar,RefreshContainer,TeachingTip,ColorPicker,NumberBox,RatingControl,SplitButton, etc. all mount and update under AOT once WindowsAppSDK#6394 is mitigated.{TemplateBinding}against built-in DPs works; private DPs declared in a separate third-party assembly without anIXamlMetadataProviderare still broken (Issue #142). - Markdown / Localization (read path) —
IStringLoc/Loc.X.Ylookups generated byReactor.Localization.Generatorare AOT-clean. Source-generated, no runtime reflection.
These subsystems compile cleanly with IsAotCompatible=true (the warnings are suppressed), but the suppressions cover real reflection that will throw at runtime under PublishAot=true. Don't rely on them in an AOT-published app until they're rebuilt on source generators.
| Subsystem | What breaks | Tracking |
|---|---|---|
| PropertyGrid auto-discovery | TypeRegistry.Resolve, ReflectionTypeMetadataProvider walk public properties, build init-only setters, and instantiate editors via Activator.CreateInstance. Manually-registered metadata is fine; auto-discovery from a runtime type is not. |
Issue #70 |
| PropertyGrid array editor | Array.CreateInstance is annotated RequiresDynamicCode. Array-typed property editors won't work AOT. |
Issue #70 |
DataGrid AutoColumns<T> |
Reflects over T's public properties via TypeRegistry. The T parameter is annotated [DynamicallyAccessedMembers(PublicProperties)] so the trimmer keeps the members, but AutoColumns ultimately funnels into TypeRegistry.Resolve, which is RequiresUnreferencedCode. Use explicit Column<T,V>(…) definitions instead. |
Issue #70 |
UseObservable on POCOs |
ObservableTreeTracker walks public properties via reflection to subscribe to INPC. Observables built explicitly (Observable<T>, IObservableCollection) are fine; the implicit-INPC path is not. |
Issue #70 |
| Form validation | FormField's default editor resolution goes through TypeRegistry. Same caveat as PropertyGrid. |
Issue #70 |
Component discovery (ReactorApp.Run<TApp> reflection paths) |
The instantiation of TApp itself is annotated and works. Devtools component enumeration is available only when the app opts into Reactor.DevtoolsSupport at build time and launches with --devtools; leave the switch off for retail/AOT builds. |
Issue #70 |
Devtools properties / setProperty DP discovery |
The tools locate DependencyProperty statics by reflection, and the trimmer keeps no reflection metadata for them unless something roots the members — so under AOT the lookups find nothing. Not specific to WinUI's CsWinRT-projected static properties: a C#-authored DP field on a custom control is equally undiscoverable. Works under JIT, which is where the devtools inner loop lives. The tool reports the likely cause rather than an empty result. See Devtools DependencyProperty discovery for the measured cost of fixing it. Affected fixture: Devtools_PropertyToolsDpDiscovery. |
Issue #1109 |
Theme resource lookup (Theme.X, ThemeRef.Resolve) |
Works once the WindowsAppSDK#6394 workaround target ships the project .pri into the publish output (see Required publish-time workarounds). Reactor's library itself is AOT-clean here. |
WindowsAppSDK#6394 |
| Third-party-assembly XAML metadata (Issue #142 fixtures) | Built-in WinUI controls work under AOT now (see Required publish-time workarounds). What remains is {TemplateBinding} against private DPs in a third-party assembly that ships no .xaml file: the XAML compiler only emits an IXamlMetadataProvider for projects that have at least one .xaml, and AOT trims any implicit metadata path. Affected fixtures: Issue142_CustomControlPrivateDp_Renders, Issue142_ThirdPartyControlPrivateDp_Renders. The library-author workaround is to register a hand-written IXamlMetadataProvider via RegisterControlAssembly. |
Issue #142 |
The devtools properties / setProperty tools find DependencyProperty statics by
reflecting over static members, and trimming removes the metadata for members nothing
roots. On a NativeAOT build the lookups therefore come back empty even for DPs that
plainly exist (issue #1109). Under JIT — where the devtools inner loop lives, since an
AOT publish takes 10-15 minutes — discovery works.
The tool says so rather than looking broken. When the inspected app is a NativeAOT
build, an empty listing carries a notice, and a failed by-name lookup appends the same
explanation to its error. Both are conditioned on the app actually being NativeAOT
(RuntimeFeature.IsDynamicCodeSupported), so a JIT build never sees them, and an AOT
build that does root its DP statics stays silent too. Without this the caller gets the
same not found they would for a typo and goes hunting for a mistake they did not make.
If you need DP inspection in an AOT build, root the DependencyProperty statics with an
ILLink descriptor. That was measured on samples/apps/hello-world-aot-devtools-on
(win-x64, TrimMode=full, OptimizationPreference=Size) before being left out of the
#1109 fix, so the cost is known: rooting only the DP getters adds +1.5 MB (11.655 →
13.196 MB) and covers all 1851 DPs across 311 WinUI types, while the blunt
preserve="all" over Microsoft.WinUI costs +16.7 MB for the same capability. Gating
the descriptor on the Reactor.DevtoolsSupport switch keeps devtools-off apps
byte-identical. It is not shipped because a generated descriptor pinned to a Windows App
SDK version needs regenerating on every SDK bump — ongoing friction for everyone, to
serve a scenario the tool is not aimed at.
Devtools are a two-layer opt-in so release and NativeAOT binaries stay trimmed by default:
-
Build-time capability — add the feature switch to an app project that needs devtools:
<ItemGroup> <RuntimeHostConfigurationOption Include="Reactor.DevtoolsSupport" Value="true" Trim="true" /> </ItemGroup>
src/Reactor/build/Reactor.targetssupplies the defaultfalsevalue. Samples enable the switch conditionally in Debug builds; Release/AOT publishes leave it off. -
Runtime activation — launch with a devtools subverb, for example:
dotnet run -- --devtools run
ReactorApp.RunandRun<TRoot>no longer takedevtools:orpreview:parameters. If a switch-off binary receives--devtools run, it writes an actionable stderr message with theRuntimeHostConfigurationOptionsnippet above instead of silently opening a normal window.
- The library compiles AOT-clean. Builds of
Reactor.csprojproduce zero IL2*/IL3* warnings. New code that reaches for reflection must either be source-generated, annotated withDynamicallyAccessedMembers, or — as a last resort — gated behind[RequiresUnreferencedCode]/[RequiresDynamicCode]so consumers see the warning at the call site. - Control families register into core, never the reverse. The core (
src/Reactor/Core/) and host (src/Reactor/Hosting/) must carry zero static type references into any specific control family (Charting, Docking, …). When the core needs to call into a subsystem, it defines a minimal interface (e.g.IChartingHostBridge,IScanExtension) and the subsystem registers a concrete implementation at first use — seeChartingRuntime.Activate()/D3ChartsHostBridge. This keeps the trimmer free to drop the whole subsystem from apps that never use it (issue #498 reclaimed ~7.8 KB of Charting —D3Color/ForcedColorsTheme/D3Charts— from chart-free AOT binaries). The boundary is pinned byCoreControlFamilyBoundaryTests, which scans the compiledReactor.dllIL and fails on any Core/Hosting → Charting/Docking reference (including ones hidden inside generic instantiations). - Suppressions are temporary. Every
[UnconditionalSuppressMessage("Trimming", ...)]or("AOT", ...)in this repo is a TODO. The justification field names the reflection use; tracking is folded into issue #70. - The benchmark canary.
tests/stress_perf/StressPerf.Reactor(and theStressPerf.Direct/ReactorGridsiblings) setPublishAot=true. If they stop publishing, an AOT regression has landed in the framework. - The runtime canary. CI's
AOT Selftestsjob (.github/workflows/ci.yml) publishestests/Reactor.AppTests.HostwithPublishAotInternal=trueand runs the full selftest suite against the NativeAOT binary on every PR.SelfTestRunner.DefaultAotSkipPatternsmutes the 16 fixtures known to fail there (PropertyGrid auto-discovery, the three Devtools fixtures whose reflection targets get trimmed —fire's handler lookup,state's hook read, and DP discovery forproperties/setProperty— the Issue142 XAML-metadata cases, hot-reload state migration, plus theUseObservableTreeproperty walk); any new failure fails the job. Note that "uses reflection" is not the same as "fails under AOT": the Devtools fixtures that run here mostly rely on type-name reflection (GetType().Name), which trimming always preserves — what breaks is member-level reflection whose target the trimmer drops. If you intentionally need to add a skip, document the bucket in the comment aboveDefaultAotSkipPatternsand re-probe withtests/Reactor.AppTests.Host/probe-aot-skips.ps1. Prefer splitting a fixture over skipping it whole — an entry mutes every check in the fixture, including ones that do pass under AOT, which is why DP discovery lives in its ownDevtools_PropertyToolsDpDiscoveryfixture. Treat that list as a liability, not a parking lot: a stale entry is AOT coverage silently switched off, so re-probe periodically and delete whatever now passes.SelfTestRunner.ValidateDefaultSkipPatternsaborts the run (TAPBail out!, exit 1, no fixtures executed) if an entry stops matching any registered fixture, so a rename can't quietly turn a skip into a no-op. - Probes can mask the defect they measure. Reflection metadata under NativeAOT is emitted from what ILCompiler can see statically, so diagnostic code that calls
typeof(T).GetProperties()/GetMembers()on a constant type roots that metadata and makes the failure disappear.DynamicallyAccessedMemberTypes.PublicPropertiescovers inherited members too, so probing one type can revive an entire base chain — a probe added to the #1109 fixture turned all 112 ofButton's DP statics visible again and made the fixture pass. Always confirm an AOT verdict with the probe deleted.
When publishing an unpackaged WinUI 3 app (WindowsPackageType=None) with PublishAot=true, the WindowsAppSDK build pipeline generates $(AssemblyName).pri into the intermediate output but does not copy it into the publish directory. The MakePRI step is conditioned on AppxPackage=true, so unpackaged apps fall through. Same applies to .xbf files generated by the XAML compiler.
The missing .pri has cascading runtime symptoms that look like separate bugs but all trace back to the same cause:
TabView's constructor throwsFileNotFoundExceptionfromResourceAccessor::GetLocalizedStringResource(SR_TabViewCloseButtonTooltipWithKA).NavigationViewmounts but its template-apply throws the sameFileNotFoundException(Reactor's error boundary catches it and renders an errorTextBlock).Application.Current.Resourcesloads as an empty dictionary (0merged,0themed,0keys) becauseApplication.LoadComponentcascades through MRT for type-info lookups.- Every
ThemeRef.Resolve(...)returnsnull;.Foreground(Theme.X)falls back to control defaults.
Upstream tracking: microsoft/WindowsAppSDK#6394 — still OPEN against 1.8 and 2.0.
Workaround (in tests/Reactor.AppTests.Host/Reactor.AppTests.Host.csproj):
<Target Name="_CopyWinUIResourcesForAot" AfterTargets="Publish"
Condition="'$(PublishAot)' == 'true' and '$(AppxPackage)' != 'true'">
<ItemGroup>
<_WinUIResourcesForAot Include="$(OutputPath)**\*.xbf" />
<_WinUIResourcesForAot Include="$(OutputPath)$(AssemblyName).pri"
Condition="Exists('$(OutputPath)$(AssemblyName).pri')" />
</ItemGroup>
<Copy SourceFiles="@(_WinUIResourcesForAot)"
DestinationFiles="@(_WinUIResourcesForAot->'$(PublishDir)%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>Any Reactor app that publishes with PublishAot=true and WindowsPackageType=None should copy this target verbatim until WindowsAppSDK ships a fix. Remove the target once #6394 closes.
tests/Reactor.AppTests.Host maintains an explicit allow-list of fixtures that hang, crash, or assert-fail under NativeAOT (SelfTestRunner.DefaultAotSkipPatterns). When you remove an entry from that list and the published Host hangs, crashes, or asserts instead of producing output, use the following workflow.
Probing each DefaultAotSkipPatterns entry in isolation (via the tests/Reactor.AppTests.Host/probe-aot-skips.ps1 helper) reveals three buckets. Pick the matching workflow:
| Bucket | Symptom | Debug step |
|---|---|---|
| Hang (dispatcher starvation) | Fixture's RunAsync() synchronously blocks the UI thread; the in-band 15 s Task.Delay watchdog cannot fire because the dispatcher isn't pumping. |
Off-dispatcher hang watchdog (60 s default, configurable via REACTOR_SELFTEST_HANG_TIMEOUT_SECONDS) writes Bail out! HANG_DETECTED: <fixture> … to stdout + stderr, flushes, then Environment.FailFast. With DOTNET_DbgEnableMiniDump=1 set, this produces a Watson minidump. |
| Native crash | Process exits with 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN) — the AOT runtime's FailFast for unhandled managed exceptions. No # Total failures: line because the process terminated abruptly. |
Set DOTNET_DbgEnableMiniDump=1 (and COMPlus_DbgEnableMiniDump=1 — both, matching DevtoolsStressE2ERunner) before launching. Open the resulting .dmp with dotnet-dump analyze or WinDbg; look at the dispatcher (UI) thread's stack for the throwing call. |
| Assertion failure | TAP output already shows not ok <name> - <reason>; process exits 1 cleanly. |
No special tooling needed — read the TAP failure line. The fixture and check name are in the message. |
The MSTest harness (Reactor.SelfTests.SelfTestBatch) parses the HANG_DETECTED signal from both stdout and stderr, and on process timeout falls back to the last # Running: line. Either way, the failure surfaces against the named fixture in the failing test's detail with a copy-pasteable repro command. It does not cascade through _initError (which would mark every unrelated fixture failed).
Set these env vars before launching the Host:
DOTNET_DbgEnableMiniDump=1
DOTNET_DbgMiniDumpType=2
DOTNET_DbgMiniDumpName=%TEMP%\reactor-selftest-%p.dmp
COMPlus_DbgEnableMiniDump=1
COMPlus_DbgMiniDumpType=2
COMPlus_DbgMiniDumpName=%TEMP%\reactor-selftest-%p.dmp
Both Environment.FailFast (hang path) and the AOT runtime's unhandled-exception fast-fail (crash path) honour these vars.
Once you have the offending fixture name, repro it standalone against the AOT-published binary:
dotnet publish tests/Reactor.AppTests.Host -p:PublishAotInternal=true -p:Platform=x64 -r win-x64 -c Release
$env:DOTNET_DbgEnableMiniDump=1
$env:DOTNET_DbgMiniDumpName="$env:TEMP\reactor-hang-%p.dmp"
& "<publish-dir>\Reactor.AppTests.Host.exe" --self-test --no-aot-skip --filter <FixtureName>--no-aot-skip bypasses the entire skip list so the targeted fixture actually runs. To drive the MSTest harness against the AOT-published binary, point it at the publish output:
$env:REACTOR_SELFTEST_HOST_EXE="<publish-dir>\Reactor.AppTests.Host.exe"
dotnet test tests/Reactor.SelfTestsWhen stepping through a fixture in a debugger, set REACTOR_SELFTEST_HANG_TIMEOUT_SECONDS=0 to suppress the hang watchdog entirely. (It also auto-disables whenever Debugger.IsAttached returns true at poll time.)
The repo includes a probe script that runs every DefaultAotSkipPatterns entry in isolation under --no-aot-skip and writes a CSV summarising whether each one passes, hangs, crashes natively, or assert-fails. Use it after AOT framework changes to find stale skips that have started passing, and to triage what's still broken.
pwsh -NoProfile -File tests\Reactor.AppTests.Host\probe-aot-skips.ps1If you need a feature listed in the "does not work" table and you're publishing AOT, file an issue against #70 with your scenario. The fix in most cases is a source generator pass; what gets prioritized is driven by who's hitting the wall.