diff --git a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt
index d04c7781d..66195e159 100644
--- a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt
+++ b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt
@@ -99,6 +99,19 @@ reconciles the dialog the same way it reconciles the rest of the tree:
silent: `OnClosed` reports dismissals, not the owner going away, so a
handler that writes back into already-unmounted state never runs.
+### Dialog theme
+
+A dialog follows the effective theme (`ActualTheme`) at its declaration's
+position, including a local ancestor override, even though WinUI displays the
+dialog outside that subtree. Changes to the owner's effective theme also update
+an already-open dialog; closing or unmounting detaches the listener.
+
+Use the existing `.RequestedTheme(ElementTheme.Dark)` modifier to override the
+real dialog's theme explicitly. Changing that declaration updates the open dialog
+in place; removing it or explicitly requesting `ElementTheme.Default` resumes
+following the owner, consistent with theme inheritance elsewhere in Reactor.
+Native `.Set(dialog => ...)` callbacks run last and can override the theme.
+
### Three-button dialogs
The three-button shape (`Primary` + `Secondary` + `Close`) is the
diff --git a/docs/guide/dialogs-and-flyouts.md b/docs/guide/dialogs-and-flyouts.md
index eb1da02e7..a060e9cd5 100644
--- a/docs/guide/dialogs-and-flyouts.md
+++ b/docs/guide/dialogs-and-flyouts.md
@@ -107,6 +107,19 @@ reconciles the dialog the same way it reconciles the rest of the tree:
silent: `OnClosed` reports dismissals, not the owner going away, so a
handler that writes back into already-unmounted state never runs.
+### Dialog theme
+
+A dialog follows the effective theme (`ActualTheme`) at its declaration's
+position, including a local ancestor override, even though WinUI displays the
+dialog outside that subtree. Changes to the owner's effective theme also update
+an already-open dialog; closing or unmounting detaches the listener.
+
+Use the existing `.RequestedTheme(ElementTheme.Dark)` modifier to override the
+real dialog's theme explicitly. Changing that declaration updates the open dialog
+in place; removing it or explicitly requesting `ElementTheme.Default` resumes
+following the owner, consistent with theme inheritance elsewhere in Reactor.
+Native `.Set(dialog => ...)` callbacks run last and can override the theme.
+
### Three-button dialogs
The three-button shape (`Primary` + `Secondary` + `Close`) is the
diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs
index bf9bb2c20..bf8f43c36 100644
--- a/src/Reactor/Core/Reconciler.cs
+++ b/src/Reactor/Core/Reconciler.cs
@@ -820,14 +820,20 @@ private static bool ForwardsAnotherElement(UIElement control, Element element)
/// placeholder's published owner= is the owner its body was declared under, so it is
/// restored for the mount. Nested components still scope their own subtrees.
///
- internal UIElement? MountDialogContent(FrameworkElement placeholder, Element content, Action requestRerender)
- {
- if (global::Microsoft.UI.Reactor.Hosting.ReactorFeatures.DevtoolsSupported && Diagnostics.ReactorSourcePublisher.IsEnabled
- && CurrentDiagnosticOwner is null
- && placeholder.GetValue(Diagnostics.ReactorDiagnostics.SourceProperty) is string published
- && Diagnostics.ReactorSourcePublisher.Owner(published) is { } owner)
- return MountUnderOwner(content, requestRerender, owner);
- return Mount(content, requestRerender);
+ internal UIElement? MountDialogContent(FrameworkElement placeholder, Element content, Action requestRerender, ElementTheme theme)
+ {
+ var previousTheme = _ambientRequestedTheme;
+ _ambientRequestedTheme = theme;
+ try
+ {
+ if (global::Microsoft.UI.Reactor.Hosting.ReactorFeatures.DevtoolsSupported && Diagnostics.ReactorSourcePublisher.IsEnabled
+ && CurrentDiagnosticOwner is null
+ && placeholder.GetValue(Diagnostics.ReactorDiagnostics.SourceProperty) is string published
+ && Diagnostics.ReactorSourcePublisher.Owner(published) is { } owner)
+ return MountUnderOwner(content, requestRerender, owner);
+ return Mount(content, requestRerender);
+ }
+ finally { _ambientRequestedTheme = previousTheme; }
}
private UIElement? MountUnderOwner(Element element, Action requestRerender, string owner)
diff --git a/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
new file mode 100644
index 000000000..8d2352523
--- /dev/null
+++ b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
@@ -0,0 +1,82 @@
+using Microsoft.UI.Xaml;
+using WinUI = Microsoft.UI.Xaml.Controls;
+
+namespace Microsoft.UI.Reactor.Core.V1Protocol;
+
+///
+/// Bridges the declaration's theme to the side-mounted dialog. XamlRoot chooses
+/// the window, but does not parent the dialog under its declaration's subtree.
+///
+internal sealed class ContentDialogThemeBinding : IDisposable
+{
+ private readonly FrameworkElement _anchor;
+ private readonly WinUI.ContentDialog _dialog;
+ private ElementTheme? _requestedTheme;
+ private ElementTheme _appliedTheme;
+ private bool _setterOverride;
+ private bool _disposed;
+
+ internal ContentDialogThemeBinding(FrameworkElement anchor, WinUI.ContentDialog dialog)
+ {
+ _anchor = anchor;
+ _dialog = dialog;
+ anchor.ActualThemeChanged += OnAnchorThemeChanged;
+ }
+
+ internal void Apply(ContentDialogElement element)
+ {
+ var requested = element.Modifiers?.RequestedTheme;
+ _requestedTheme = requested is ElementTheme.Default ? null : requested;
+ _setterOverride = false;
+ ApplyTheme(_requestedTheme ?? _anchor.ActualTheme);
+ }
+
+ // .Set is the escape hatch and runs last. Do not overwrite a theme it chose
+ // when an ancestor later changes; nor re-run arbitrary setters from an event.
+ internal void ApplySetters(ContentDialogElement element)
+ {
+ if (element.Setters.Length == 0)
+ {
+ _setterOverride = false;
+ ApplyTheme(_requestedTheme ?? _anchor.ActualTheme);
+ return;
+ }
+
+ // A write of the same value is still an explicit override. Value-change
+ // notifications cannot distinguish that from a setter that never wrote.
+ _dialog.ClearValue(FrameworkElement.RequestedThemeProperty);
+ try
+ {
+ Reconciler.ApplySetters(element.Setters, _dialog);
+ }
+ finally
+ {
+ _setterOverride = _dialog.ReadLocalValue(FrameworkElement.RequestedThemeProperty)
+ != DependencyProperty.UnsetValue;
+ if (!_setterOverride)
+ ApplyTheme(_requestedTheme ?? _anchor.ActualTheme);
+ }
+ }
+
+ private void OnAnchorThemeChanged(FrameworkElement sender, object args)
+ {
+ if (_disposed || _requestedTheme.HasValue || _setterOverride
+ || _dialog.RequestedTheme != _appliedTheme)
+ return;
+ ApplyTheme(_anchor.ActualTheme);
+ }
+
+ private void ApplyTheme(ElementTheme theme)
+ {
+ _appliedTheme = theme;
+ if (_dialog.RequestedTheme != theme)
+ _dialog.RequestedTheme = theme;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _anchor.ActualThemeChanged -= OnAnchorThemeChanged;
+ }
+}
diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
index fbc5b1a57..316814f9e 100644
--- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
+++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
@@ -53,7 +53,9 @@ internal static class OverlayLifecycle
// is poolable, so the placeholder can be recycled into an unrelated element
// — ContentDialogHandler.Unmount takes the entry back out during the V1
// unmount dispatch, which runs before the control reaches the pool.
- private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable s_liveDialogs = new();
+ private sealed record LiveContentDialog(WinUI.ContentDialog Dialog, ContentDialogThemeBinding Theme);
+
+ private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable s_liveDialogs = new();
public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogElement cdEl, Action requestRerender)
{
@@ -77,20 +79,26 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE
// No live dialog: never opened, still deferred waiting on XamlRoot, or
// already dismissed. A rising edge above is the only way back.
- if (!s_liveDialogs.TryGetValue(fe, out var dialog)) return null;
+ if (!s_liveDialogs.TryGetValue(fe, out var live)) return null;
+ var dialog = live.Dialog;
- if (!n.IsOpen && o.IsOpen)
+ if (!n.IsOpen)
{
// Falling edge — honor the declared IsOpen. Hide() drives ShowAsync's
// continuation, which unmounts the content and dispatches OnClosed,
// exactly as a user dismissal does (and matching Popup, whose
// IsOpen = false likewise raises Closed).
- dialog.Hide();
+ // Stop following as soon as closing starts, not only when ShowAsync
+ // completes after the close transition. Intervening renders must not
+ // re-sync a dialog that is already on its way out.
+ live.Theme.Dispose();
+ if (o.IsOpen) dialog.Hide();
return null;
}
SyncContentDialogProps(dialog, n);
TagDialogForDiagnostics(dialog, n);
+ live.Theme.Apply(n);
// Reconcile the side-mounted content in place so transient state inside
// the dialog (focus, scroll, caret) survives the owner's re-renders.
@@ -106,7 +114,7 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE
dialog.Content = reconciler.Mount(n.Content, requestRerender) as UIElement;
}
- Reconciler.ApplySetters(n.Setters, dialog);
+ live.Theme.ApplySetters(n);
return null;
}
@@ -136,14 +144,15 @@ private static void SyncContentDialogProps(WinUI.ContentDialog dialog, ContentDi
///
internal static WinUI.ContentDialog? TryTakeLiveContentDialog(FrameworkElement anchor)
{
- if (!s_liveDialogs.TryGetValue(anchor, out var dialog)) return null;
+ if (!s_liveDialogs.TryGetValue(anchor, out var live)) return null;
s_liveDialogs.Remove(anchor);
- return dialog;
+ live.Theme.Dispose();
+ return live.Dialog;
}
/// The dialog currently showing for , without taking it.
internal static WinUI.ContentDialog? PeekLiveContentDialog(FrameworkElement anchor)
- => s_liveDialogs.TryGetValue(anchor, out var dialog) ? dialog : null;
+ => s_liveDialogs.TryGetValue(anchor, out var live) ? live.Dialog : null;
private static void ShowContentDialog(Reconciler reconciler, ContentDialogElement cdEl, FrameworkElement anchor, Action requestRerender)
{
@@ -255,7 +264,6 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi
};
if (cdEl.SecondaryButtonText is not null) dialog.SecondaryButtonText = cdEl.SecondaryButtonText;
if (cdEl.CloseButtonText is not null) dialog.CloseButtonText = cdEl.CloseButtonText;
- dialog.Content = reconciler.MountDialogContent(anchor, cdEl.Content, requestRerender);
if (xamlRoot is not null) dialog.XamlRoot = xamlRoot;
TagDialogForDiagnostics(dialog, cdEl);
// Resolve callbacks through the anchor's live Tag the way Flyout/Popup do,
@@ -263,10 +271,10 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi
// while open so those closures go stale, and clearing the tag is how
// unmount teardown suppresses an OnClosed it did not mean to raise.
dialog.Opened += (_, _) => (Reconciler.GetElementTag(anchor) as ContentDialogElement)?.OnOpened?.Invoke();
- // ApplySetters last so caller .Set(...) wins (including overriding XamlRoot).
- Reconciler.ApplySetters(cdEl.Setters, dialog);
+ var theme = new ContentDialogThemeBinding(anchor, dialog);
+ var liveDialog = new LiveContentDialog(dialog, theme);
// Publish before showing so the very next render can reach the dialog.
- s_liveDialogs.AddOrUpdate(anchor, dialog);
+ s_liveDialogs.AddOrUpdate(anchor, liveDialog);
// The visible dialog describes the element too, not only its collapsed placeholder: an
// out-of-process inspector sees the dialog. A deferred (Loaded) open finds the
// placeholder already published; a mount-time open is covered by the reconciler, which
@@ -282,6 +290,10 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi
WinUI.ContentDialogResult winUiResult;
try
{
+ theme.Apply(cdEl);
+ dialog.Content = reconciler.MountDialogContent(anchor, cdEl.Content, requestRerender, dialog.RequestedTheme);
+ // ApplySetters last so caller .Set(...) wins (including theme/XamlRoot).
+ theme.ApplySetters(cdEl);
winUiResult = await dialog.ShowAsync();
}
finally
@@ -289,7 +301,8 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi
// In a finally so it also runs when ShowAsync throws — WinUI rejects
// a second dialog on the same XamlRoot — which would otherwise strand
// the tracking entry and leak the content subtree mounted above.
- ownsClose = s_liveDialogs.TryGetValue(anchor, out var tracked) && ReferenceEquals(tracked, dialog);
+ theme.Dispose();
+ ownsClose = s_liveDialogs.TryGetValue(anchor, out var tracked) && ReferenceEquals(tracked, liveDialog);
if (ownsClose) s_liveDialogs.Remove(anchor);
// A caller can keep the dialog through .Set(...); a closed dialog must not keep
// resolving to (and keeping alive) its element, nor keep the published
diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs
new file mode 100644
index 000000000..c3a25dd44
--- /dev/null
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs
@@ -0,0 +1,254 @@
+using Microsoft.UI.Reactor.Core;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Automation;
+using static Microsoft.UI.Reactor.Factories;
+using WinUI = Microsoft.UI.Xaml.Controls;
+
+namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;
+
+internal sealed class ContentDialogThemeFixture(Harness h) : SelfTestFixtureBase(h)
+{
+ private const string Title = "Dialog theme probe";
+
+ public override async Task RunAsync()
+ {
+ var appTheme = Application.Current.RequestedTheme == ApplicationTheme.Dark
+ ? ElementTheme.Dark : ElementTheme.Light;
+ var opposite = appTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark;
+ var state = new ScenarioState(opposite);
+ Action? setState = null;
+ var host = H.CreateHost();
+ host.Mount(_ => Component(new(
+ state, setter => setState = setter)));
+ var mounted = await ContentDialogProbe.WaitForOpen(H, Title)
+ ?? throw new InvalidOperationException("Dialog did not open.");
+ await Harness.WaitFor(() => ContentDialogProbe.Walk(mounted, _ => true) is not null);
+ var editor = ContentDialogProbe.Walk(mounted, _ => true)
+ ?? throw new InvalidOperationException("Draft editor did not realize.");
+ editor.Text = "unsaved theme-switch draft";
+ editor.Select(2, 4);
+ try
+ {
+ // The owner is opposite to the application, so accidentally using
+ // the app fallback cannot pass the default-inheritance assertion.
+ await CheckTheme("InheritsAtDeferredOpen", opposite);
+
+ var themeChanges = 0;
+ var themeToken = mounted.RegisterPropertyChangedCallback(
+ FrameworkElement.RequestedThemeProperty, (_, _) => themeChanges++);
+ try
+ {
+ // Prove the observer catches real theme writes before using a
+ // zero count to detect unnecessary clear-and-restore work.
+ mounted.RequestedTheme = appTheme;
+ mounted.RequestedTheme = opposite;
+ H.Check("DialogTheme_ThemeWriteObserverPositiveControl", themeChanges == 2);
+ themeChanges = 0;
+
+ state = state with { ButtonEnabled = false };
+ setState!(state);
+ await Harness.Render();
+ H.Check("DialogTheme_NoSetterRenderUpdatesDialog", !mounted.IsPrimaryButtonEnabled);
+ H.Check("DialogTheme_NoSetterRenderKeepsThemeStable", themeChanges == 0
+ && mounted.RequestedTheme == opposite);
+
+ state = state with { ButtonEnabled = true };
+ setState(state);
+ await Harness.Render();
+ H.Check("DialogTheme_RepeatedNoSetterRenderKeepsThemeStable", themeChanges == 0
+ && mounted.IsPrimaryButtonEnabled && mounted.RequestedTheme == opposite);
+ }
+ finally
+ {
+ mounted.UnregisterPropertyChangedCallback(FrameworkElement.RequestedThemeProperty, themeToken);
+ }
+
+ state = state with { ParentTheme = appTheme };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("FollowsParentRender", appTheme);
+
+ var owner = H.FindControl(panel =>
+ AutomationProperties.GetName(panel) == "Dialog theme owner")
+ ?? throw new InvalidOperationException("Dialog owner is missing.");
+ // Establish a contradictory starting value even if the preceding
+ // parent-render check failed. Otherwise a missing subscription could
+ // leave the dialog at `opposite` and this next check would pass for
+ // precisely the wrong reason.
+ Current().RequestedTheme = appTheme;
+ if (!await Harness.WaitFor(() => Current().ActualTheme == appTheme))
+ throw new InvalidOperationException("Native theme-change precondition could not be established.");
+ // No Reactor render: prove the ActualThemeChanged subscription,
+ // rather than merely testing that Update copies a theme property.
+ owner.RequestedTheme = opposite;
+ await CheckTheme("FollowsNativeAncestorChange", opposite);
+
+ state = state with { DialogTheme = appTheme };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("ExplicitThemeReachesRealDialog", appTheme);
+ owner.RequestedTheme = appTheme;
+ await Harness.Render();
+ owner.RequestedTheme = opposite;
+ await CheckTheme("ExplicitThemeWinsOverAncestor", appTheme);
+
+ state = state with { DialogTheme = opposite };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("ExplicitThemeUpdatesWhileOpen", opposite);
+
+ state = state with { DialogTheme = ElementTheme.Default };
+ setState(state);
+ await Harness.Render();
+ await CheckTheme("ExplicitDefaultFollowsOwner", opposite);
+ owner.RequestedTheme = appTheme;
+ await CheckTheme("ExplicitDefaultFollowsNativeChange", appTheme);
+ owner.RequestedTheme = opposite;
+
+ state = state with { DialogTheme = null };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("RemovingOverrideResumesFollowing", opposite);
+ owner.RequestedTheme = appTheme;
+ await CheckTheme("FollowingRemainsLiveAfterRemoval", appTheme);
+
+ state = state with { NativeOverride = true };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("NativeSetterWins", opposite);
+ owner.RequestedTheme = opposite;
+ await Harness.Render();
+ owner.RequestedTheme = appTheme;
+ await CheckTheme("FollowingDoesNotOverwriteNativeSetter", opposite);
+
+ // Setter equals the followed value. A value-comparison detector
+ // misses this assignment and later lets the owner overwrite it.
+ owner.RequestedTheme = opposite;
+ state = state with { ParentTheme = opposite, NativeOverride = true };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("EqualNativeSetterWins", opposite);
+ // The owner's modifier is applied after its children update. Render
+ // once more with that owner already settled, changing only a dialog
+ // prop so the setter is observed against the same followed value.
+ state = state with { ButtonEnabled = false };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("EqualNativeSetterAfterOwnerSettles", opposite);
+ owner.RequestedTheme = appTheme;
+ await Harness.WaitFor(() => owner.ActualTheme == appTheme);
+ await Harness.Render();
+ await CheckTheme("EqualNativeSetterRemainsPinned", opposite);
+
+ state = state with { NativeOverride = false, ParentTheme = appTheme, ButtonEnabled = true };
+ setState!(state);
+ await Harness.Render();
+ await CheckTheme("RemovingNativeSetterResumesFollowing", appTheme);
+
+ WinUI.ContentDialogClosingDeferral? closingDeferral = null;
+ mounted.Closing += (_, args) => closingDeferral = args.GetDeferral();
+ try
+ {
+ state = state with { IsOpen = false };
+ setState!(state);
+ await Harness.Render();
+ if (!await Harness.WaitFor(() => closingDeferral is not null))
+ throw new InvalidOperationException("Dialog did not enter deferred closing.");
+ var closingTheme = mounted.RequestedTheme;
+ state = state with { ParentTheme = opposite, ButtonEnabled = false };
+ setState(state);
+ await Harness.Render();
+ owner.RequestedTheme = opposite;
+ await Harness.Render();
+ H.Check("DialogTheme_ClosingStopsFollowing", mounted.RequestedTheme == closingTheme,
+ $"While closing: expected {closingTheme}; actual {mounted.RequestedTheme}");
+ H.Check("DialogTheme_ClosingIgnoresPropUpdates", mounted.IsPrimaryButtonEnabled);
+ }
+ finally { closingDeferral?.Complete(); }
+ H.Check("DialogTheme_Closes", await ContentDialogProbe.WaitForClosed(H, Title));
+
+ state = state with { IsOpen = true };
+ setState!(state);
+ await Harness.Render();
+ mounted = await ContentDialogProbe.WaitForOpen(H, Title)
+ ?? throw new InvalidOperationException("Dialog did not reopen.");
+ await Harness.WaitFor(() => ContentDialogProbe.Walk(mounted, _ => true) is not null);
+ var reopenedEditor = ContentDialogProbe.Walk(mounted, _ => true)
+ ?? throw new InvalidOperationException("Reopened editor did not realize.");
+ // Pooling may legitimately reuse the old native TextBox. The user
+ // contract is a reset draft, not allocation of a different object.
+ H.Check("DialogTheme_ReopenResetsDraft", reopenedEditor.Text == "seed");
+ editor = reopenedEditor;
+ editor.Text = "unsaved theme-switch draft";
+ editor.Select(2, 4);
+ await CheckTheme("ReopenUsesCurrentOwnerTheme", opposite);
+
+ state = state with { IncludeDialog = false };
+ setState!(state);
+ await Harness.Render();
+ H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title));
+ }
+ finally
+ {
+ mounted.Hide();
+ await ContentDialogProbe.WaitForClosed(H, Title);
+ }
+
+ WinUI.ContentDialog Current() => ContentDialogProbe.FindOpen(H, Title)
+ ?? throw new InvalidOperationException("Dialog disappeared during theme update.");
+
+ async Task CheckTheme(string check, ElementTheme expected)
+ {
+ await Harness.WaitFor(() => Current().ActualTheme == expected
+ && ContentDialogProbe.FindText(Current(), "Themed dialog content")?.ActualTheme == expected);
+ var current = Current();
+ H.Check("DialogTheme_" + check, ReferenceEquals(current, mounted)
+ && current.RequestedTheme == expected && current.ActualTheme == expected
+ && ContentDialogProbe.FindText(current, "Themed dialog content")?.ActualTheme == expected,
+ $"Expected {expected}; requested {current.RequestedTheme}; actual {current.ActualTheme}");
+ var liveEditor = ContentDialogProbe.Walk(current, _ => true);
+ H.Check("DialogTheme_" + check + "_PreservesDraft", ReferenceEquals(liveEditor, editor)
+ && liveEditor?.Text == "unsaved theme-switch draft"
+ && liveEditor.SelectionStart == 2 && liveEditor.SelectionLength == 4);
+ }
+ }
+
+ private sealed record ScenarioState(
+ ElementTheme ParentTheme,
+ ElementTheme? DialogTheme = null,
+ bool IsOpen = true,
+ bool IncludeDialog = true,
+ bool NativeOverride = false,
+ bool ButtonEnabled = true);
+
+ private sealed record ScenarioProps(
+ ScenarioState Initial,
+ Action> CaptureSetter);
+
+ private sealed class Scenario : Component
+ {
+ public override Element Render()
+ {
+ var (state, setState) = UseState(Props.Initial);
+ Props.CaptureSetter(setState);
+ var dialog = ContentDialog(Title,
+ VStack(TextBlock("Themed dialog content"), TextBox("seed")), "OK") with
+ {
+ IsOpen = state.IsOpen,
+ IsPrimaryButtonEnabled = state.ButtonEnabled,
+ };
+ if (state.DialogTheme is { } theme) dialog = dialog.RequestedTheme(theme);
+ if (state.NativeOverride)
+ {
+ var opposite = Application.Current.RequestedTheme == ApplicationTheme.Dark
+ ? ElementTheme.Light : ElementTheme.Dark;
+ dialog = dialog.Set(control => control.RequestedTheme = opposite);
+ }
+ return VStack(
+ TextBlock("Owner"),
+ state.IncludeDialog ? dialog : null)
+ .AutomationName("Dialog theme owner")
+ .RequestedTheme(state.ParentTheme);
+ }
+ }
+}
diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs
new file mode 100644
index 000000000..949c1b858
--- /dev/null
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs
@@ -0,0 +1,149 @@
+using Microsoft.UI.Reactor.Core;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using static Microsoft.UI.Reactor.Factories;
+using WinUI = Microsoft.UI.Xaml.Controls;
+using Color = global::Windows.UI.Color;
+
+namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;
+
+///
+/// Compare the rendered dialog to a conventional native WinUI dialog, not to a
+/// second invocation of Reactor's theme resolver. Each case has a contradictory
+/// outer theme, and both Light/Dark run regardless of the machine's app theme.
+/// Separate fixtures keep a default-inheritance failure from hiding explicit
+/// theme failures in a pre-fix/mutation run.
+///
+internal sealed class ContentDialogThemeOpenMatrixFixture(Harness h, bool explicitTheme) : SelfTestFixtureBase(h)
+{
+ private const string Title = "Dialog theme open matrix";
+ private const string ContentText = "Native theme comparison";
+
+ public override async Task RunAsync()
+ {
+ foreach (var requested in new[] { ElementTheme.Light, ElementTheme.Dark, ElementTheme.Default })
+ {
+ // Default on an inherited owner just repeats the application fallback;
+ // test Default as an explicit request against an opposite local owner.
+ if (!explicitTheme && requested == ElementTheme.Default) continue;
+ var referenceTheme = requested == ElementTheme.Default
+ ? Application.Current.RequestedTheme == ApplicationTheme.Dark ? ElementTheme.Light : ElementTheme.Dark
+ : requested;
+ var reference = await ReadNativeReference(referenceTheme);
+ var opposite = reference.ActualTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark;
+ foreach (var openAtMount in new[] { true, false })
+ {
+ var caseName = $"{(explicitTheme ? "Explicit" : "Inherited")}_{requested}_{(openAtMount ? "Deferred" : "RisingEdge")}";
+ var state = new ScenarioState(openAtMount);
+ Action? setState = null;
+ var host = H.CreateHost();
+ host.Mount(_ => Component(new(
+ state,
+ // The nearest owner disagrees with the outer root, so using
+ // the window root instead of the declaration site is wrong.
+ explicitTheme && requested != ElementTheme.Default ? reference.ActualTheme : opposite,
+ explicitTheme && requested != ElementTheme.Default ? opposite : reference.ActualTheme,
+ explicitTheme ? requested : null,
+ setter => setState = setter)));
+ if (!openAtMount)
+ {
+ await Harness.Render();
+ H.Check("DialogThemeMatrix_" + caseName + "_InitiallyClosed",
+ ContentDialogProbe.FindOpen(H, Title) is null);
+ setState!(state with { IsOpen = true });
+ }
+
+ var dialog = await ContentDialogProbe.WaitForOpen(H, Title)
+ ?? throw new InvalidOperationException($"{caseName}: dialog did not open.");
+ try
+ {
+ await Harness.WaitFor(() => ContentDialogProbe.FindText(dialog, ContentText)?.Foreground is SolidColorBrush);
+ // Re-find in the popup after it realizes. Reading only the
+ // element record or a control captured before opening cannot
+ // prove the real, visible dialog received the theme.
+ var current = ContentDialogProbe.FindOpen(H, Title)!;
+ var text = ContentDialogProbe.FindText(current, ContentText);
+ var expectedRequest = explicitTheme && requested != ElementTheme.Default ? requested : reference.ActualTheme;
+ H.Check("DialogThemeMatrix_" + caseName + "_RequestedTheme",
+ current.RequestedTheme == expectedRequest,
+ $"App={Application.Current.RequestedTheme}; expected={expectedRequest}; actual={current.RequestedTheme}");
+ H.Check("DialogThemeMatrix_" + caseName + "_RenderedTheme",
+ current.ActualTheme == reference.ActualTheme && text?.ActualTheme == reference.ActualTheme,
+ $"Native={reference.ActualTheme}; dialog={current.ActualTheme}; content={text?.ActualTheme}");
+ var foreground = (text?.Foreground as SolidColorBrush)?.Color;
+ H.Check("DialogThemeMatrix_" + caseName + "_NativeForeground",
+ foreground == reference.Foreground,
+ $"Native={reference.Foreground}; Reactor={foreground}");
+ H.Check("DialogThemeMatrix_" + caseName + "_ContentThemeResource",
+ text?.Resources["DialogThemeProbeBrush"] is SolidColorBrush resourceBrush
+ && resourceBrush.Color == reference.Foreground,
+ $"ThemeRef must resolve against native reference theme {reference.ActualTheme} on first open.");
+ }
+ finally
+ {
+ dialog.Hide();
+ await ContentDialogProbe.WaitForClosed(H, Title);
+ }
+ }
+ }
+ }
+
+ private async Task ReadNativeReference(ElementTheme theme)
+ {
+ // Plain WinUI is the positive control: no Reactor modifiers or binding
+ // helper participate in computing the expected rendered theme/brush.
+ var native = new WinUI.ContentDialog
+ {
+ XamlRoot = H.Window.Content.XamlRoot,
+ Title = Title,
+ Content = new WinUI.TextBlock { Text = ContentText },
+ PrimaryButtonText = "OK",
+ RequestedTheme = theme,
+ };
+ var showing = native.ShowAsync();
+ try
+ {
+ var opened = await ContentDialogProbe.WaitForOpen(H, Title)
+ ?? throw new InvalidOperationException("Native positive-control dialog did not open.");
+ await Harness.WaitFor(() => ContentDialogProbe.FindText(opened, ContentText)?.Foreground is SolidColorBrush);
+ var text = ContentDialogProbe.FindText(opened, ContentText)
+ ?? throw new InvalidOperationException("Native reference text did not realize.");
+ if (text.Foreground is not SolidColorBrush brush)
+ throw new InvalidOperationException("Native reference text has no measurable solid foreground.");
+ H.Check($"DialogThemeMatrix_Native_{theme}_PositiveControl",
+ ReferenceEquals(opened, native) && native.RequestedTheme == theme
+ && text.ActualTheme == native.ActualTheme
+ && (theme == ElementTheme.Default || native.ActualTheme == theme));
+ return new(native.ActualTheme, brush.Color);
+ }
+ finally
+ {
+ native.Hide();
+ await showing;
+ await ContentDialogProbe.WaitForClosed(H, Title);
+ }
+ }
+
+ private sealed record NativeReference(ElementTheme ActualTheme, Color Foreground);
+ private sealed record ScenarioState(bool IsOpen);
+ private sealed record ScenarioProps(
+ ScenarioState Initial,
+ ElementTheme RootTheme,
+ ElementTheme OwnerTheme,
+ ElementTheme? DialogTheme,
+ Action> CaptureSetter);
+
+ private sealed class Scenario : Component
+ {
+ public override Element Render()
+ {
+ var (state, setState) = UseState(Props.Initial);
+ Props.CaptureSetter(setState);
+ var dialog = ContentDialog(Title, TextBlock(ContentText)
+ .Resources(resources => resources.Set("DialogThemeProbeBrush", Theme.PrimaryText)), "OK")
+ with { IsOpen = state.IsOpen };
+ if (Props.DialogTheme is { } theme) dialog = dialog.RequestedTheme(theme);
+ return VStack(VStack(dialog).RequestedTheme(Props.OwnerTheme)).RequestedTheme(Props.RootTheme);
+ }
+ }
+}
diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs
new file mode 100644
index 000000000..cdef861f3
--- /dev/null
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs
@@ -0,0 +1,84 @@
+using Microsoft.UI.Reactor.Core;
+using Microsoft.UI.Reactor.Hosting;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using static Microsoft.UI.Reactor.Factories;
+using WinUI = Microsoft.UI.Xaml.Controls;
+
+namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;
+
+internal sealed class ContentDialogThemeSecondaryWindowFixture(Harness h) : SelfTestFixtureBase(h)
+{
+ private const string Title = "Secondary dialog theme";
+
+ public override async Task RunAsync()
+ {
+ var appTheme = Application.Current.RequestedTheme == ApplicationTheme.Dark
+ ? ElementTheme.Dark : ElementTheme.Light;
+ var opposite = appTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark;
+ var primaryHost = H.CreateHost();
+ primaryHost.Mount(_ => VStack(TextBlock("Primary window")).RequestedTheme(appTheme));
+ var harnessRoot = (FrameworkElement)H.Window.Content;
+ var originalTheme = harnessRoot.ReadLocalValue(FrameworkElement.RequestedThemeProperty);
+ var secondary = new Window { Title = "Secondary theme owner" };
+ var host = new ReactorHost(secondary);
+ try
+ {
+ host.Mount(_ => VStack(ContentDialog(Title, TextBlock("Secondary content"), "OK")
+ with { IsOpen = true }).RequestedTheme(opposite));
+ secondary.Activate();
+ // A newly allocated XamlRoot may exist before its visual tree is
+ // connected. GetOpenPopupsForXamlRoot rejects that intermediate root.
+ if (!await Harness.WaitFor(() => secondary.Content is FrameworkElement { IsLoaded: true }
+ && secondary.Content.XamlRoot?.IsHostVisible == true))
+ throw new InvalidOperationException("Secondary window did not finish loading.");
+ await Harness.WaitFor(() => Find(secondary.Content.XamlRoot) is not null);
+ var dialog = Find(secondary.Content.XamlRoot)
+ ?? throw new InvalidOperationException("Secondary dialog did not open in its own XamlRoot.");
+ await Harness.WaitFor(() => ContentDialogProbe.FindText(dialog, "Secondary content") is not null);
+ H.Check("DialogThemeSecondary_UsesOwningWindow", dialog.XamlRoot == secondary.Content.XamlRoot
+ && dialog.XamlRoot != H.Window.Content.XamlRoot
+ && ContentDialogProbe.FindOpen(H, Title) is null);
+ CheckTheme("Initial", opposite, dialog);
+ ((FrameworkElement)secondary.Content).RequestedTheme = appTheme;
+ await Harness.WaitFor(() => Find(secondary.Content.XamlRoot)?.ActualTheme == appTheme);
+ CheckTheme("FollowsOnlyItsOwnOwner", appTheme, dialog);
+
+ harnessRoot.RequestedTheme = opposite;
+ await Harness.Render();
+ CheckTheme("OtherWindowDoesNotOverride", appTheme, dialog);
+ }
+ finally
+ {
+ host.Dispose();
+ secondary.Close();
+ // The harness root outlives this fixture; restore its own declaration.
+ if (ReferenceEquals(originalTheme, DependencyProperty.UnsetValue))
+ harnessRoot.ClearValue(FrameworkElement.RequestedThemeProperty);
+ else
+ harnessRoot.SetValue(FrameworkElement.RequestedThemeProperty, originalTheme);
+ await ContentDialogProbe.WaitForNoneOpen(H);
+ }
+
+ void CheckTheme(string name, ElementTheme expected, WinUI.ContentDialog original)
+ {
+ var current = Find(secondary.Content.XamlRoot);
+ H.Check("DialogThemeSecondary_" + name, ReferenceEquals(current, original)
+ && current?.RequestedTheme == expected && current.ActualTheme == expected
+ && ContentDialogProbe.FindText(current, "Secondary content")?.ActualTheme == expected,
+ $"App={appTheme}; expected={expected}; actual={current?.ActualTheme}");
+ }
+ }
+
+ private static WinUI.ContentDialog? Find(XamlRoot? root)
+ {
+ if (root is null) return null;
+ foreach (var popup in VisualTreeHelper.GetOpenPopupsForXamlRoot(root))
+ {
+ if (popup.Child is not null && ContentDialogProbe.Walk(
+ popup.Child, dialog => dialog.Title as string == Title) is { } dialog)
+ return dialog;
+ }
+ return null;
+ }
+}
diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs
index 3be8122bd..8fca76c12 100644
--- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ReactorSourceFixture.cs
@@ -1423,13 +1423,17 @@ public override async Task RunAsync()
var openValue = kept is null ? null : ReactorDiagnostics.GetSource(kept);
var openSite = kept is null ? null : Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.GetSource(kept);
shownDialog?.Hide();
- await Harness.Render(50);
- await Harness.Render(50);
+ // Hide starts an asynchronous close transition. Its ShowAsync
+ // continuation owns attribution cleanup, so two fixed frame
+ // delays can observe a dialog that has not finished closing.
+ var attributionCleared = await Harness.WaitFor(() =>
+ kept is not null && ReactorDiagnostics.GetSource(kept) is null);
var closedValue = kept is null ? null : ReactorDiagnostics.GetSource(kept);
var closedSite = kept is null ? null : Microsoft.UI.Reactor.Diagnostics.ReactorSourceMap.GetSource(kept);
Console.WriteLine($"# kept dialog ({title}): open {openValue} @ {openSite}; closed {closedValue} @ {closedSite}");
H.Check($"ReactorSource_LiveDialog_{title}_ClosedDialogStopsDescribingItsElement",
openValue?.Contains("|element=ContentDialog", StringComparison.Ordinal) == true
+ && attributionCleared
&& closedValue is null
&& (openSite is null || closedSite is null));
keptHost.Dispose();
@@ -1758,4 +1762,4 @@ public override async Task RunAsync()
ReactorSourcePublisher.NoManagedAgent = noAgent;
}
}
-}
\ No newline at end of file
+}
diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs
index 3cd8f9dd9..849ad80b5 100644
--- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs
+++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs
@@ -368,6 +368,10 @@ internal static class SelfTestFixtureRegistry
"ContentDialogLive_RoutesClosedToLatestRender",
"ContentDialogLive_ReopensAfterClose",
"Diagnostics_ContentDialogTag",
+ "ContentDialogTheme_Lifecycle",
+ "ContentDialogTheme_ExplicitOpenMatrix",
+ "ContentDialogTheme_InheritedOpenMatrix",
+ "ContentDialogTheme_SecondaryWindow",
// Flyout placement guard — Reactor must never write FlyoutPlacementMode.Auto
// onto a WinUI FlyoutBase, because the show-time validator rejects it and
// fail-fasts the process. The Flyout/ContentFlyout/fresh-create fixtures
@@ -2503,6 +2507,10 @@ internal static string[] StaleTierDeclarations() =>
"ContentDialogLive_RoutesClosedToLatestRender" => new ContentDialogLiveContentFixtures.ContentDialog_RoutesClosedToLatestRender(harness),
"ContentDialogLive_ReopensAfterClose" => new ContentDialogLiveContentFixtures.ContentDialog_ReopensAfterClose(harness),
"Diagnostics_ContentDialogTag" => new ContentDialogTagFixtures.DialogIsTagged(harness),
+ "ContentDialogTheme_Lifecycle" => new ContentDialogThemeFixture(harness),
+ "ContentDialogTheme_ExplicitOpenMatrix" => new ContentDialogThemeOpenMatrixFixture(harness, explicitTheme: true),
+ "ContentDialogTheme_InheritedOpenMatrix" => new ContentDialogThemeOpenMatrixFixture(harness, explicitTheme: false),
+ "ContentDialogTheme_SecondaryWindow" => new ContentDialogThemeSecondaryWindowFixture(harness),
// Flyout placement guard — Auto must never reach FlyoutBase.Placement.
"FlyoutPlacement_Platform_FlyoutBase_PlacementDefault" => new FlyoutPlacementFixtures.Platform_FlyoutBase_PlacementDefault(harness),
"FlyoutPlacement_Flyout_DefaultPlacement_Opens" => new FlyoutPlacementFixtures.Flyout_DefaultPlacement_Opens(harness),