Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Reactor.sln
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chat.UI", "samples\apps\cha
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DemoScriptTool", "samples\apps\demo-script-tool\App\DemoScriptTool.csproj", "{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reactor.AppTests.ThirdPartyControls", "tests\Reactor.AppTests.ThirdPartyControls\Reactor.AppTests.ThirdPartyControls.csproj", "{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|ARM64 = Debug|ARM64
Expand Down Expand Up @@ -1343,6 +1345,22 @@ Global
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}.Release|Any CPU.Build.0 = Release|x64
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}.Release|x86.ActiveCfg = Release|x64
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379}.Release|x86.Build.0 = Release|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|ARM64.ActiveCfg = Debug|ARM64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|ARM64.Build.0 = Debug|ARM64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x64.ActiveCfg = Debug|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x64.Build.0 = Debug|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|Any CPU.ActiveCfg = Debug|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|Any CPU.Build.0 = Debug|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x86.ActiveCfg = Debug|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Debug|x86.Build.0 = Debug|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|ARM64.ActiveCfg = Release|ARM64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|ARM64.Build.0 = Release|ARM64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x64.ActiveCfg = Release|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x64.Build.0 = Release|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|Any CPU.ActiveCfg = Release|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|Any CPU.Build.0 = Release|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x86.ActiveCfg = Release|x64
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA}.Release|x86.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1436,5 +1454,6 @@ Global
{EC3DCAD8-8304-4121-8B56-C0FDB0FCFD86} = {4E734D91-FF91-9C86-E2D6-07A7D63D64AF}
{EDDCA324-BB03-4726-8423-902BF4BA9255} = {4E734D91-FF91-9C86-E2D6-07A7D63D64AF}
{A3F705B7-B379-49DB-89D6-4AD9DA5C2379} = {2F1648C1-FA56-3C81-D04A-E07825801750}
{438E858B-BCB9-424E-8F36-7E03ACD8C0EA} = {D1E2F3A4-B5C6-7890-1234-56789ABCDEF1}
EndGlobalSection
EndGlobal
171 changes: 168 additions & 3 deletions src/Reactor/Hosting/ReactorApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,89 @@ public static ReactorHost? ActiveHost

private static int _previewParamDeprecationWarned;

// ── XAML control-assembly registration ─────────────────────────────────
//
// The lifted XAML loader resolves `local:` namespaces and Generic.xaml type
// references through Application.Current's IXamlMetadataProvider chain.
// ReactorApplication auto-discovers the *entry assembly's* compiler-generated
// provider, but that breaks down for third-party control libraries when the
// consuming Reactor app has no XAML files of its own — in that case the
// app's compiler-generated provider doesn't exist, so referenced libraries
// never get chained. Registered providers fill that gap.
//
// CopyOnWrite snapshot semantics so reads from GetXamlType (called on the UI
// thread, hot path) need no locking.
private static IXamlMetadataProvider[] _registeredXamlMetadataProviders = [];
private static readonly object _registeredXamlMetadataProvidersLock = new();

/// <summary>
/// Registers a XAML metadata provider so its types are visible to the WinUI
/// XAML loader for this process. Required when a third-party control library
/// is referenced from a Reactor app that has no XAML files of its own (and
/// therefore no compiler-generated provider that would auto-chain to the
/// library). Call before <see cref="Run{TRoot}(string, int, int, bool, bool, bool, Action{ReactorHost}?)"/>.
/// Idempotent (same instance is added at most once) and thread-safe.
/// See https://github.com/microsoft/microsoft-ui-reactor/issues/142.
/// </summary>
public static void RegisterControlAssembly(IXamlMetadataProvider provider)
{
ArgumentNullException.ThrowIfNull(provider);
lock (_registeredXamlMetadataProvidersLock)
{
var current = _registeredXamlMetadataProviders;
if (Array.IndexOf(current, provider) >= 0) return;
var next = new IXamlMetadataProvider[current.Length + 1];
Array.Copy(current, next, current.Length);
next[^1] = provider;
Volatile.Write(ref _registeredXamlMetadataProviders, next);
}
}

/// <summary>
/// Convenience overload that locates the XAML-compiler-generated
/// <c>IXamlMetadataProvider</c> in <paramref name="assembly"/> (the type the
/// XAML compiler emits when the project has at least one XAML file) and
/// registers it. Throws if no such provider is found — pass the
/// <see cref="IXamlMetadataProvider"/> instance directly if your library
/// uses a non-standard provider type.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Caller-supplied assembly's XAML metadata provider is preserved by the XAML compiler that emits it.")]
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Parameterless ctor invoked on a freshly-discovered IXamlMetadataProvider type.")]
[UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "Reflection over caller-supplied assembly types; XAML compiler preserves IXamlMetadataProvider implementations.")]
public static void RegisterControlAssembly(global::System.Reflection.Assembly assembly)
{
ArgumentNullException.ThrowIfNull(assembly);
var provider = FindXamlMetadataProviderInAssembly(assembly)
?? throw new InvalidOperationException(
$"No IXamlMetadataProvider found in {assembly.GetName().Name}. " +
"The XAML compiler only generates one when the project has at least one XAML file. " +
"If you have a hand-written provider, pass the instance directly to RegisterControlAssembly.");
RegisterControlAssembly(provider);
}

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "See RegisterControlAssembly(Assembly).")]
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "See RegisterControlAssembly(Assembly).")]
[UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "See RegisterControlAssembly(Assembly).")]
internal static IXamlMetadataProvider? FindXamlMetadataProviderInAssembly(global::System.Reflection.Assembly assembly)
{
global::System.Type[] types;
try { types = assembly.GetTypes(); }
catch (global::System.Reflection.ReflectionTypeLoadException ex) { types = ex.Types.OfType<global::System.Type>().ToArray(); }

foreach (var t in types)
{
if (!typeof(IXamlMetadataProvider).IsAssignableFrom(t)) continue;
if (t.IsAbstract || t.IsInterface) continue;
if (t.GetConstructor(global::System.Type.EmptyTypes) is null) continue;
try { return (IXamlMetadataProvider)global::System.Activator.CreateInstance(t)!; }
catch { /* keep scanning — a broken candidate must not deny a valid one */ }
}
return null;
}

internal static IXamlMetadataProvider[] RegisteredControlAssemblyProviders
=> Volatile.Read(ref _registeredXamlMetadataProviders);

// Session-scoped flag. True iff the process was launched with a devtools
// subverb (--devtools app / --devtools run) AND the developer passed
// devtools: true to Run. Frozen after startup; read by UseDevtools() and
Expand Down Expand Up @@ -688,6 +771,41 @@ private static IXamlMetadataProvider CreateReactorProvider()
private IXamlMetadataProvider? _coreProvider;
private IXamlMetadataProvider CoreProvider => _coreProvider ??= new Hosting.ReactorCoreXamlMetadataProvider();

// Provider for the consuming app's own XAML-compiler-generated metadata. Without this,
// a custom Control declared in the user's project crashes when WinUI loads its
// Themes/Generic.xaml because `local:` namespace lookups go through Application.Current
// (which is this ReactorApplication) and our chain only knew about Reactor's own types.
// Empty providers (apps with no custom XAML types) collapse to a no-op stub and cost
// one reflection scan of the entry assembly at startup.
// See https://github.com/microsoft/microsoft-ui-reactor/issues/142.
private IXamlMetadataProvider? _hostAppProvider;
private IXamlMetadataProvider HostAppProvider => _hostAppProvider ??= DiscoverHostAppProvider();

private static IXamlMetadataProvider DiscoverHostAppProvider()
{
// The XAML compiler emits one IXamlMetadataProvider per project that has any XAML
// file (typically named `<Sanitized(AssemblyName)>_XamlTypeInfo.XamlMetaDataProvider`,
// but the exact name varies). Scanning the entry assembly is robust to that drift.
var entry = global::System.Reflection.Assembly.GetEntryAssembly();
if (entry is null) return EmptyXamlMetadataProvider.Instance;

var found = ReactorApp.FindXamlMetadataProviderInAssembly(entry);
// Reactor's own generated provider lives in the Reactor assembly; if the entry
// assembly happens to BE Reactor (unit-test hosting), ReactorProvider already
// covers it and we'd just be re-finding the same type.
if (found is not null && found.GetType().FullName != "Microsoft.UI.Reactor.Reactor_XamlTypeInfo.XamlMetaDataProvider")
return found;
return EmptyXamlMetadataProvider.Instance;
}

private sealed partial class EmptyXamlMetadataProvider : IXamlMetadataProvider
{
public static readonly EmptyXamlMetadataProvider Instance = new();
public IXamlType? GetXamlType(Type type) => null;
public IXamlType? GetXamlType(string fullName) => null;
public XmlnsDefinition[] GetXmlnsDefinitions() => [];
}

/// <summary>
/// Optional callback for unhandled exceptions. If set, called before deciding whether to handle.
/// Return true to mark the exception as handled; return false (or leave null) to let it crash.
Expand Down Expand Up @@ -747,8 +865,55 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
// here is the WinUI convention for "unknown type" even though the WinRT interface
// types it as non-nullable.
public IXamlType GetXamlType(Type type)
=> (ReactorProvider.GetXamlType(type) ?? CoreProvider.GetXamlType(type))!;
{
var t = ReactorProvider.GetXamlType(type);
if (t is not null) return t;
t = HostAppProvider.GetXamlType(type);
if (t is not null) return t;
foreach (var p in ReactorApp.RegisteredControlAssemblyProviders)
{
t = p.GetXamlType(type);
if (t is not null) return t;
}
return CoreProvider.GetXamlType(type)!;
}

public IXamlType GetXamlType(string fullName)
=> (ReactorProvider.GetXamlType(fullName) ?? CoreProvider.GetXamlType(fullName))!;
public XmlnsDefinition[] GetXmlnsDefinitions() => ReactorProvider.GetXmlnsDefinitions();
{
var t = ReactorProvider.GetXamlType(fullName);
if (t is not null) return t;
t = HostAppProvider.GetXamlType(fullName);
if (t is not null) return t;
foreach (var p in ReactorApp.RegisteredControlAssemblyProviders)
{
t = p.GetXamlType(fullName);
if (t is not null) return t;
}
return CoreProvider.GetXamlType(fullName)!;
}

public XmlnsDefinition[] GetXmlnsDefinitions()
{
var reactor = ReactorProvider.GetXmlnsDefinitions();
var host = HostAppProvider.GetXmlnsDefinitions();
var registered = ReactorApp.RegisteredControlAssemblyProviders;
var registeredCount = 0;
var registeredDefs = new XmlnsDefinition[registered.Length][];
for (var i = 0; i < registered.Length; i++)
{
registeredDefs[i] = registered[i].GetXmlnsDefinitions() ?? [];
registeredCount += registeredDefs[i].Length;
}
if (host.Length == 0 && registeredCount == 0) return reactor;
var combined = new XmlnsDefinition[reactor.Length + host.Length + registeredCount];
var offset = 0;
global::System.Array.Copy(reactor, 0, combined, offset, reactor.Length); offset += reactor.Length;
global::System.Array.Copy(host, 0, combined, offset, host.Length); offset += host.Length;
for (var i = 0; i < registeredDefs.Length; i++)
{
global::System.Array.Copy(registeredDefs[i], 0, combined, offset, registeredDefs[i].Length);
offset += registeredDefs[i].Length;
}
return combined;
}
}
4 changes: 4 additions & 0 deletions tests/Reactor.AppTests.Host/Reactor.AppTests.Host.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

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

</Project>
126 changes: 126 additions & 0 deletions tests/Reactor.AppTests.Host/SelfTest/Fixtures/Issue142Fixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using Microsoft.UI.Reactor.AppTests.Host.SelfTest;
using Microsoft.UI.Reactor.Hosting;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using static Microsoft.UI.Reactor.Factories;

namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;

/// <summary>
/// Repro for https://github.com/microsoft/microsoft-ui-reactor/issues/142
///
/// Custom control whose backing <c>DependencyProperty</c> field is
/// <c>private static readonly</c>. WinUI's lifted XAML parser fails to
/// resolve the DP by name when the default Style applies (loaded from
/// <c>Themes/Generic.xaml</c>):
/// "Failed to create a 'Microsoft.UI.Xaml.DependencyProperty' from the text 'MyText'."
///
/// Mirrors the customer's WinUI Class Library + Custom Control template repro.
/// </summary>
public sealed partial class CustomControlWithPrivateDp : Control
{
public CustomControlWithPrivateDp()
{
DefaultStyleKey = typeof(CustomControlWithPrivateDp);
}

public string MyText
{
get => (string)GetValue(MyTextProperty);
private set => SetValue(MyTextProperty, value);
}

// Intentionally private — the bug repro hinges on this being non-public.
private static readonly DependencyProperty MyTextProperty =
DependencyProperty.Register(
nameof(MyText),
typeof(string),
typeof(CustomControlWithPrivateDp),
new PropertyMetadata("MyCustomControl"));
}

internal static class Issue142Fixtures
{
internal class CustomControlPrivateDp_Renders(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
var host = H.CreateHost();
host.Mount(_ =>
VStack(
TextBlock("Issue142 Repro"),
new XamlHostElement(() => new CustomControlWithPrivateDp())
)
);

await Harness.Render();

H.Check("Issue142_TitleVisible",
H.FindText("Issue142 Repro") is not null);

var custom = H.FindControl<CustomControlWithPrivateDp>(_ => true);
H.Check("Issue142_CustomControlMounted", custom is not null);

// The ControlTemplate's TextBlock should render the default DP
// value ("MyCustomControl") via {TemplateBinding MyText}. If the
// XAML parser fails to resolve the DP, the Template setter is
// dropped and no TextBlock with that text appears.
H.Check("Issue142_TemplateBoundTextRendered",
H.FindText("MyCustomControl") is not null);
}
}

/// <summary>
/// Variant of the issue #142 repro where the custom control lives in a
/// *separate* assembly (Reactor.AppTests.ThirdPartyControls), simulating a
/// real third-party control NuGet. The entry-assembly auto-discovery in
/// <see cref="ReactorApp"/> can't see this provider; the consuming app
/// must opt in via <see cref="ReactorApp.RegisterControlAssembly(global::System.Reflection.Assembly)"/>
/// — analogous to Win2D / CommunityToolkit's documented setup in pure WinUI.
///
/// We also assert that the registration API actually surfaces a non-empty
/// provider list, which is the part that makes a difference for no-XAML
/// consumer apps where the compiler's auto-chaining doesn't run.
/// </summary>
internal class ThirdPartyControlPrivateDp_Renders(Harness h) : SelfTestFixtureBase(h)
{
public override async Task RunAsync()
{
// Register before the 3P control instance is created. In a Reactor app with
// *no XAML files of its own*, this call is required: there's no consuming-app
// compiler-generated XamlMetaDataProvider whose `OtherProviders` would
// transitively chain to the 3P library, so the lifted XAML loader otherwise
// can't resolve `tp:` or {TemplateBinding MyText} from the 3P Generic.xaml.
// (This test host happens to have its own Themes/Generic.xaml, so the bug
// wouldn't crash here without this call — but the registration is what real
// no-XAML Reactor apps need, and we verify the plumbing below.)
ReactorApp.RegisterControlAssembly(typeof(global::Reactor.AppTests.ThirdPartyControls.ThirdPartyControlWithPrivateDp).Assembly);

H.Check("Issue142_3P_ProviderRegistered",
ReactorApp.RegisteredControlAssemblyProviders.Length > 0);

var host = H.CreateHost();
host.Mount(_ =>
VStack(
TextBlock("Issue142 3P Repro"),
new XamlHostElement(() => new global::Reactor.AppTests.ThirdPartyControls.ThirdPartyControlWithPrivateDp())
)
);

await Harness.Render();

H.Check("Issue142_3P_TitleVisible",
H.FindText("Issue142 3P Repro") is not null);

var custom = H.FindControl<global::Reactor.AppTests.ThirdPartyControls.ThirdPartyControlWithPrivateDp>(_ => true);
H.Check("Issue142_3P_ControlMounted", custom is not null);

// ControlTemplate in the 3P library's Generic.xaml renders the default
// value ("ThirdPartyDefault") through {TemplateBinding MyText}. If the
// registered provider isn't consulted, the parse fails and no TextBlock
// with that text appears.
H.Check("Issue142_3P_TemplateBoundTextRendered",
H.FindText("ThirdPartyDefault") is not null);
}
}
}
Loading
Loading