Skip to content

Commit 8d296c9

Browse files
fix(hosting): chain entry-assembly and registered XAML metadata providers
WinUI's lifted XAML loader resolves `local:` namespaces and Generic.xaml type/property references through `Application.Current`'s `IXamlMetadataProvider` chain. `ReactorApplication` previously only delegated to Reactor's own generated provider plus a hand-written core stub, so any custom `Control` declared in the consuming app — or in a referenced third-party library when the consumer has no XAML of its own — went unresolved, and the lifted parser took the process down with a `Failed to create a 'Microsoft.UI.Xaml.DependencyProperty' from the text 'MyText'` originate error before the control could render. Two-part fix: 1. Auto-discover the entry assembly's XAML-compiler-generated `XamlMetaDataProvider` and chain it between Reactor's provider and the core stub. Covers the common case where the consumer has any XAML file (which transitively chains every referenced library through the compiler-generated `OtherProviders` list). 2. Add `ReactorApp.RegisterControlAssembly(IXamlMetadataProvider)` and `RegisterControlAssembly(Assembly)` for the no-XAML-consumer case, where there is no compiler-generated chain to ride. Idempotent, thread-safe, copy-on-write snapshot semantics so the hot lookup path needs no locking. Mirrors the documented Win2D / CommunityToolkit pattern in pure WinUI apps. Selftests: `Issue142_CustomControlPrivateDp_Renders` exercises path 1 via a control declared in the host project; `Issue142_ThirdPartyControlPrivateDp_Renders` exercises path 2 via the new `Reactor.AppTests.ThirdPartyControls` library that simulates a real 3P control NuGet. Fixes #142 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 875d7ee commit 8d296c9

9 files changed

Lines changed: 416 additions & 3 deletions

File tree

Reactor.sln

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chat.UI", "samples\apps\cha
195195
EndProject
196196
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DemoScriptTool", "samples\apps\demo-script-tool\App\DemoScriptTool.csproj", "{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}"
197197
EndProject
198+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reactor.AppTests.ThirdPartyControls", "tests\Reactor.AppTests.ThirdPartyControls\Reactor.AppTests.ThirdPartyControls.csproj", "{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}"
199+
EndProject
198200
Global
199201
GlobalSection(SolutionConfigurationPlatforms) = preSolution
200202
Debug|ARM64 = Debug|ARM64
@@ -1343,6 +1345,22 @@ Global
13431345
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}.Release|Any CPU.Build.0 = Release|x64
13441346
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}.Release|x86.ActiveCfg = Release|x64
13451347
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}.Release|x86.Build.0 = Release|x64
1348+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|ARM64.ActiveCfg = Debug|ARM64
1349+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|ARM64.Build.0 = Debug|ARM64
1350+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x64.ActiveCfg = Debug|x64
1351+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x64.Build.0 = Debug|x64
1352+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|Any CPU.ActiveCfg = Debug|x64
1353+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|Any CPU.Build.0 = Debug|x64
1354+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x86.ActiveCfg = Debug|x64
1355+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x86.Build.0 = Debug|x64
1356+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|ARM64.ActiveCfg = Release|ARM64
1357+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|ARM64.Build.0 = Release|ARM64
1358+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x64.ActiveCfg = Release|x64
1359+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x64.Build.0 = Release|x64
1360+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|Any CPU.ActiveCfg = Release|x64
1361+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|Any CPU.Build.0 = Release|x64
1362+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x86.ActiveCfg = Release|x64
1363+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x86.Build.0 = Release|x64
13461364
EndGlobalSection
13471365
GlobalSection(SolutionProperties) = preSolution
13481366
HideSolutionNode = FALSE
@@ -1436,5 +1454,6 @@ Global
14361454
{EC3DCAD8-8304-4121-8B56-C0FDB0FCFD86} = {4E734D91-FF91-9C86-E2D6-07A7D63D64AF}
14371455
{EDDCA324-BB03-4726-8423-902BF4BA9255} = {4E734D91-FF91-9C86-E2D6-07A7D63D64AF}
14381456
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379} = {2F1648C1-FA56-3C81-D04A-E07825801750}
1457+
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA} = {D1E2F3A4-B5C6-7890-1234-56789ABCDEF1}
14391458
EndGlobalSection
14401459
EndGlobal

src/Reactor/Hosting/ReactorApp.cs

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,90 @@ public static ReactorHost? ActiveHost
4444

4545
private static int _previewParamDeprecationWarned;
4646

47+
// ── XAML control-assembly registration ─────────────────────────────────
48+
//
49+
// The lifted XAML loader resolves `local:` namespaces and Generic.xaml type
50+
// references through Application.Current's IXamlMetadataProvider chain.
51+
// ReactorApplication auto-discovers the *entry assembly's* compiler-generated
52+
// provider, but that breaks down for third-party control libraries when the
53+
// consuming Reactor app has no XAML files of its own — in that case the
54+
// app's compiler-generated provider doesn't exist, so referenced libraries
55+
// never get chained. Registered providers fill that gap.
56+
//
57+
// CopyOnWrite snapshot semantics so reads from GetXamlType (called on the UI
58+
// thread, hot path) need no locking.
59+
private static IXamlMetadataProvider[] _registeredXamlMetadataProviders = [];
60+
private static readonly object _registeredXamlMetadataProvidersLock = new();
61+
62+
/// <summary>
63+
/// Registers a XAML metadata provider so its types are visible to the WinUI
64+
/// XAML loader for this process. Required when a third-party control library
65+
/// is referenced from a Reactor app that has no XAML files of its own (and
66+
/// therefore no compiler-generated provider that would auto-chain to the
67+
/// library). Call before <see cref="Run{TRoot}(string, int, int, bool, bool, bool, Action{ReactorHost}?)"/>.
68+
/// Idempotent (same instance is added at most once) and thread-safe.
69+
/// See https://github.com/microsoft/microsoft-ui-reactor/issues/142.
70+
/// </summary>
71+
public static void RegisterControlAssembly(IXamlMetadataProvider provider)
72+
{
73+
ArgumentNullException.ThrowIfNull(provider);
74+
lock (_registeredXamlMetadataProvidersLock)
75+
{
76+
var current = _registeredXamlMetadataProviders;
77+
if (Array.IndexOf(current, provider) >= 0) return;
78+
var next = new IXamlMetadataProvider[current.Length + 1];
79+
Array.Copy(current, next, current.Length);
80+
next[^1] = provider;
81+
Volatile.Write(ref _registeredXamlMetadataProviders, next);
82+
}
83+
}
84+
85+
/// <summary>
86+
/// Convenience overload that locates the XAML-compiler-generated
87+
/// <c>IXamlMetadataProvider</c> in <paramref name="assembly"/> (the type the
88+
/// XAML compiler emits when the project has at least one XAML file) and
89+
/// registers it. Throws if no such provider is found — pass the
90+
/// <see cref="IXamlMetadataProvider"/> instance directly if your library
91+
/// uses a non-standard provider type.
92+
/// </summary>
93+
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Caller-supplied assembly's XAML metadata provider is preserved by the XAML compiler that emits it.")]
94+
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Parameterless ctor invoked on a freshly-discovered IXamlMetadataProvider type.")]
95+
[UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection over caller-supplied assembly types; XAML compiler preserves IXamlMetadataProvider implementations.")]
96+
public static void RegisterControlAssembly(global::System.Reflection.Assembly assembly)
97+
{
98+
ArgumentNullException.ThrowIfNull(assembly);
99+
var provider = FindXamlMetadataProviderInAssembly(assembly)
100+
?? throw new InvalidOperationException(
101+
$"No IXamlMetadataProvider found in {assembly.GetName().Name}. " +
102+
"The XAML compiler only generates one when the project has at least one XAML file. " +
103+
"If you have a hand-written provider, pass the instance directly to RegisterControlAssembly.");
104+
RegisterControlAssembly(provider);
105+
}
106+
107+
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "See RegisterControlAssembly(Assembly).")]
108+
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "See RegisterControlAssembly(Assembly).")]
109+
[UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "See RegisterControlAssembly(Assembly).")]
110+
internal static IXamlMetadataProvider? FindXamlMetadataProviderInAssembly(global::System.Reflection.Assembly assembly)
111+
{
112+
global::System.Type[] types;
113+
try { types = assembly.GetTypes(); }
114+
catch (global::System.Reflection.ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
115+
116+
foreach (var t in types)
117+
{
118+
if (t is null) continue;
119+
if (!typeof(IXamlMetadataProvider).IsAssignableFrom(t)) continue;
120+
if (t.IsAbstract || t.IsInterface) continue;
121+
if (t.GetConstructor(global::System.Type.EmptyTypes) is null) continue;
122+
try { return (IXamlMetadataProvider)global::System.Activator.CreateInstance(t)!; }
123+
catch { /* keep scanning — a broken candidate must not deny a valid one */ }
124+
}
125+
return null;
126+
}
127+
128+
internal static IXamlMetadataProvider[] RegisteredControlAssemblyProviders
129+
=> Volatile.Read(ref _registeredXamlMetadataProviders);
130+
47131
// Session-scoped flag. True iff the process was launched with a devtools
48132
// subverb (--devtools app / --devtools run) AND the developer passed
49133
// devtools: true to Run. Frozen after startup; read by UseDevtools() and
@@ -688,6 +772,41 @@ private static IXamlMetadataProvider CreateReactorProvider()
688772
private IXamlMetadataProvider? _coreProvider;
689773
private IXamlMetadataProvider CoreProvider => _coreProvider ??= new Hosting.ReactorCoreXamlMetadataProvider();
690774

775+
// Provider for the consuming app's own XAML-compiler-generated metadata. Without this,
776+
// a custom Control declared in the user's project crashes when WinUI loads its
777+
// Themes/Generic.xaml because `local:` namespace lookups go through Application.Current
778+
// (which is this ReactorApplication) and our chain only knew about Reactor's own types.
779+
// Empty providers (apps with no custom XAML types) collapse to a no-op stub and cost
780+
// one reflection scan of the entry assembly at startup.
781+
// See https://github.com/microsoft/microsoft-ui-reactor/issues/142.
782+
private IXamlMetadataProvider? _hostAppProvider;
783+
private IXamlMetadataProvider HostAppProvider => _hostAppProvider ??= DiscoverHostAppProvider();
784+
785+
private static IXamlMetadataProvider DiscoverHostAppProvider()
786+
{
787+
// The XAML compiler emits one IXamlMetadataProvider per project that has any XAML
788+
// file (typically named `<Sanitized(AssemblyName)>_XamlTypeInfo.XamlMetaDataProvider`,
789+
// but the exact name varies). Scanning the entry assembly is robust to that drift.
790+
var entry = global::System.Reflection.Assembly.GetEntryAssembly();
791+
if (entry is null) return EmptyXamlMetadataProvider.Instance;
792+
793+
var found = ReactorApp.FindXamlMetadataProviderInAssembly(entry);
794+
// Reactor's own generated provider lives in the Reactor assembly; if the entry
795+
// assembly happens to BE Reactor (unit-test hosting), ReactorProvider already
796+
// covers it and we'd just be re-finding the same type.
797+
if (found is not null && found.GetType().FullName != "Microsoft.UI.Reactor.Reactor_XamlTypeInfo.XamlMetaDataProvider")
798+
return found;
799+
return EmptyXamlMetadataProvider.Instance;
800+
}
801+
802+
private sealed partial class EmptyXamlMetadataProvider : IXamlMetadataProvider
803+
{
804+
public static readonly EmptyXamlMetadataProvider Instance = new();
805+
public IXamlType? GetXamlType(Type type) => null;
806+
public IXamlType? GetXamlType(string fullName) => null;
807+
public XmlnsDefinition[] GetXmlnsDefinitions() => [];
808+
}
809+
691810
/// <summary>
692811
/// Optional callback for unhandled exceptions. If set, called before deciding whether to handle.
693812
/// Return true to mark the exception as handled; return false (or leave null) to let it crash.
@@ -747,8 +866,55 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
747866
// here is the WinUI convention for "unknown type" even though the WinRT interface
748867
// types it as non-nullable.
749868
public IXamlType GetXamlType(Type type)
750-
=> (ReactorProvider.GetXamlType(type) ?? CoreProvider.GetXamlType(type))!;
869+
{
870+
var t = ReactorProvider.GetXamlType(type);
871+
if (t is not null) return t;
872+
t = HostAppProvider.GetXamlType(type);
873+
if (t is not null) return t;
874+
foreach (var p in ReactorApp.RegisteredControlAssemblyProviders)
875+
{
876+
t = p.GetXamlType(type);
877+
if (t is not null) return t;
878+
}
879+
return CoreProvider.GetXamlType(type)!;
880+
}
881+
751882
public IXamlType GetXamlType(string fullName)
752-
=> (ReactorProvider.GetXamlType(fullName) ?? CoreProvider.GetXamlType(fullName))!;
753-
public XmlnsDefinition[] GetXmlnsDefinitions() => ReactorProvider.GetXmlnsDefinitions();
883+
{
884+
var t = ReactorProvider.GetXamlType(fullName);
885+
if (t is not null) return t;
886+
t = HostAppProvider.GetXamlType(fullName);
887+
if (t is not null) return t;
888+
foreach (var p in ReactorApp.RegisteredControlAssemblyProviders)
889+
{
890+
t = p.GetXamlType(fullName);
891+
if (t is not null) return t;
892+
}
893+
return CoreProvider.GetXamlType(fullName)!;
894+
}
895+
896+
public XmlnsDefinition[] GetXmlnsDefinitions()
897+
{
898+
var reactor = ReactorProvider.GetXmlnsDefinitions();
899+
var host = HostAppProvider.GetXmlnsDefinitions();
900+
var registered = ReactorApp.RegisteredControlAssemblyProviders;
901+
var registeredCount = 0;
902+
var registeredDefs = new XmlnsDefinition[registered.Length][];
903+
for (var i = 0; i < registered.Length; i++)
904+
{
905+
registeredDefs[i] = registered[i].GetXmlnsDefinitions() ?? [];
906+
registeredCount += registeredDefs[i].Length;
907+
}
908+
if (host.Length == 0 && registeredCount == 0) return reactor;
909+
var combined = new XmlnsDefinition[reactor.Length + host.Length + registeredCount];
910+
var offset = 0;
911+
global::System.Array.Copy(reactor, 0, combined, offset, reactor.Length); offset += reactor.Length;
912+
global::System.Array.Copy(host, 0, combined, offset, host.Length); offset += host.Length;
913+
for (var i = 0; i < registeredDefs.Length; i++)
914+
{
915+
global::System.Array.Copy(registeredDefs[i], 0, combined, offset, registeredDefs[i].Length);
916+
offset += registeredDefs[i].Length;
917+
}
918+
return combined;
919+
}
754920
}

tests/Reactor.AppTests.Host/Reactor.AppTests.Host.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919

2020
<ItemGroup>
2121
<ProjectReference Include="..\..\src\Reactor\Reactor.csproj" />
22+
<!-- Standalone library used by Issue142_ThirdPartyControlPrivateDp_Renders to
23+
simulate a 3P control NuGet referenced from a Reactor app that has its
24+
own XAML metadata. -->
25+
<ProjectReference Include="..\Reactor.AppTests.ThirdPartyControls\Reactor.AppTests.ThirdPartyControls.csproj" />
2226
</ItemGroup>
2327

2428
</Project>
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Microsoft.UI.Reactor.AppTests.Host.SelfTest;
2+
using Microsoft.UI.Reactor.Hosting;
3+
using Microsoft.UI.Xaml;
4+
using Microsoft.UI.Xaml.Controls;
5+
using static Microsoft.UI.Reactor.Factories;
6+
7+
namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;
8+
9+
/// <summary>
10+
/// Repro for https://github.com/microsoft/microsoft-ui-reactor/issues/142
11+
///
12+
/// Custom control whose backing <c>DependencyProperty</c> field is
13+
/// <c>private static readonly</c>. WinUI's lifted XAML parser fails to
14+
/// resolve the DP by name when the default Style applies (loaded from
15+
/// <c>Themes/Generic.xaml</c>):
16+
/// "Failed to create a 'Microsoft.UI.Xaml.DependencyProperty' from the text 'MyText'."
17+
///
18+
/// Mirrors the customer's WinUI Class Library + Custom Control template repro.
19+
/// </summary>
20+
public sealed partial class CustomControlWithPrivateDp : Control
21+
{
22+
public CustomControlWithPrivateDp()
23+
{
24+
DefaultStyleKey = typeof(CustomControlWithPrivateDp);
25+
}
26+
27+
public string MyText
28+
{
29+
get => (string)GetValue(MyTextProperty);
30+
private set => SetValue(MyTextProperty, value);
31+
}
32+
33+
// Intentionally private — the bug repro hinges on this being non-public.
34+
private static readonly DependencyProperty MyTextProperty =
35+
DependencyProperty.Register(
36+
nameof(MyText),
37+
typeof(string),
38+
typeof(CustomControlWithPrivateDp),
39+
new PropertyMetadata("MyCustomControl"));
40+
}
41+
42+
internal static class Issue142Fixtures
43+
{
44+
internal class CustomControlPrivateDp_Renders(Harness h) : SelfTestFixtureBase(h)
45+
{
46+
public override async Task RunAsync()
47+
{
48+
var host = H.CreateHost();
49+
host.Mount(_ =>
50+
VStack(
51+
TextBlock("Issue142 Repro"),
52+
new XamlHostElement(() => new CustomControlWithPrivateDp())
53+
)
54+
);
55+
56+
await Harness.Render();
57+
58+
H.Check("Issue142_TitleVisible",
59+
H.FindText("Issue142 Repro") is not null);
60+
61+
var custom = H.FindControl<CustomControlWithPrivateDp>(_ => true);
62+
H.Check("Issue142_CustomControlMounted", custom is not null);
63+
64+
// The ControlTemplate's TextBlock should render the default DP
65+
// value ("MyCustomControl") via {TemplateBinding MyText}. If the
66+
// XAML parser fails to resolve the DP, the Template setter is
67+
// dropped and no TextBlock with that text appears.
68+
H.Check("Issue142_TemplateBoundTextRendered",
69+
H.FindText("MyCustomControl") is not null);
70+
}
71+
}
72+
73+
/// <summary>
74+
/// Variant of the issue #142 repro where the custom control lives in a
75+
/// *separate* assembly (Reactor.AppTests.ThirdPartyControls), simulating a
76+
/// real third-party control NuGet. The entry-assembly auto-discovery in
77+
/// <see cref="ReactorApp"/> can't see this provider; the consuming app
78+
/// must opt in via <see cref="ReactorApp.RegisterControlAssembly(global::System.Reflection.Assembly)"/>
79+
/// — analogous to Win2D / CommunityToolkit's documented setup in pure WinUI.
80+
///
81+
/// We also assert that the registration API actually surfaces a non-empty
82+
/// provider list, which is the part that makes a difference for no-XAML
83+
/// consumer apps where the compiler's auto-chaining doesn't run.
84+
/// </summary>
85+
internal class ThirdPartyControlPrivateDp_Renders(Harness h) : SelfTestFixtureBase(h)
86+
{
87+
public override async Task RunAsync()
88+
{
89+
// Register before the 3P control instance is created. In a Reactor app with
90+
// *no XAML files of its own*, this call is required: there's no consuming-app
91+
// compiler-generated XamlMetaDataProvider whose `OtherProviders` would
92+
// transitively chain to the 3P library, so the lifted XAML loader otherwise
93+
// can't resolve `tp:` or {TemplateBinding MyText} from the 3P Generic.xaml.
94+
// (This test host happens to have its own Themes/Generic.xaml, so the bug
95+
// wouldn't crash here without this call — but the registration is what real
96+
// no-XAML Reactor apps need, and we verify the plumbing below.)
97+
ReactorApp.RegisterControlAssembly(typeof(global::Reactor.AppTests.ThirdPartyControls.ThirdPartyControlWithPrivateDp).Assembly);
98+
99+
H.Check("Issue142_3P_ProviderRegistered",
100+
ReactorApp.RegisteredControlAssemblyProviders.Length > 0);
101+
102+
var host = H.CreateHost();
103+
host.Mount(_ =>
104+
VStack(
105+
TextBlock("Issue142 3P Repro"),
106+
new XamlHostElement(() => new global::Reactor.AppTests.ThirdPartyControls.ThirdPartyControlWithPrivateDp())
107+
)
108+
);
109+
110+
await Harness.Render();
111+
112+
H.Check("Issue142_3P_TitleVisible",
113+
H.FindText("Issue142 3P Repro") is not null);
114+
115+
var custom = H.FindControl<global::Reactor.AppTests.ThirdPartyControls.ThirdPartyControlWithPrivateDp>(_ => true);
116+
H.Check("Issue142_3P_ControlMounted", custom is not null);
117+
118+
// ControlTemplate in the 3P library's Generic.xaml renders the default
119+
// value ("ThirdPartyDefault") through {TemplateBinding MyText}. If the
120+
// registered provider isn't consulted, the parse fails and no TextBlock
121+
// with that text appears.
122+
H.Check("Issue142_3P_TemplateBoundTextRendered",
123+
H.FindText("ThirdPartyDefault") is not null);
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)