From d57a53fcea345eab846df0dabcdc84dd2ed00253 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?kusutori=E3=82=AF=E3=82=B9=E3=81=A8=E3=82=8A?=
<100983934+kusutori@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:23:30 +0800
Subject: [PATCH 1/2] Fix ContentDialog theme forwarding and inheritance
---
.../templates/dialogs-and-flyouts.md.dt | 13 ++
docs/guide/dialogs-and-flyouts.md | 13 ++
.../V1Protocol/ContentDialogThemeBinding.cs | 58 ++++++
.../Core/V1Protocol/OverlayLifecycle.cs | 35 +++-
.../Fixtures/ContentDialogThemeFixture.cs | 186 ++++++++++++++++++
.../ContentDialogThemeOpenMatrixFixture.cs | 140 +++++++++++++
...ontentDialogThemeSecondaryWindowFixture.cs | 84 ++++++++
.../SelfTest/SelfTestFixtureRegistry.cs | 8 +
8 files changed, 527 insertions(+), 10 deletions(-)
create mode 100644 src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs
create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs
create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeSecondaryWindowFixture.cs
diff --git a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt
index d04c7781d..a5735f42f 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 resumes following the owner. An explicit
+`ElementTheme.Default` is still a native theme request, not an absent override.
+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..cd981bd5a 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 resumes following the owner. An explicit
+`ElementTheme.Default` is still a native theme request, not an absent override.
+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/V1Protocol/ContentDialogThemeBinding.cs b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
new file mode 100644
index 000000000..087bfa39f
--- /dev/null
+++ b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
@@ -0,0 +1,58 @@
+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)
+ {
+ _requestedTheme = element.Modifiers?.RequestedTheme;
+ _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 ObserveSetters() => _setterOverride = _dialog.RequestedTheme != _appliedTheme;
+
+ 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 a1cfb53da..7797b330f 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.
@@ -107,6 +115,7 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE
}
Reconciler.ApplySetters(n.Setters, dialog);
+ live.Theme.ObserveSetters();
return null;
}
@@ -136,9 +145,10 @@ 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;
}
private static void ShowContentDialog(Reconciler reconciler, ContentDialogElement cdEl, FrameworkElement anchor, Action requestRerender)
@@ -259,10 +269,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);
// True when this call still owns the placeholder's tracking entry at
// close time. False once unmount teardown has taken it, or once a
// re-open has installed a newer dialog over it.
@@ -270,6 +280,10 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi
WinUI.ContentDialogResult winUiResult;
try
{
+ theme.Apply(cdEl);
+ // ApplySetters last so caller .Set(...) wins (including theme/XamlRoot).
+ Reconciler.ApplySetters(cdEl.Setters, dialog);
+ theme.ObserveSetters();
winUiResult = await dialog.ShowAsync();
}
finally
@@ -277,7 +291,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. No-op when it was never tagged.
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..3dc772ccd
--- /dev/null
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs
@@ -0,0 +1,186 @@
+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;
+ FrameworkElement? anchor = null;
+ var host = H.CreateHost();
+ host.Mount(_ => Component(new(
+ state, setter => setState = setter, element => anchor = element)));
+ 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);
+
+ state = state with { ParentTheme = appTheme };
+ setState!(state);
+ 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 CheckTheme("ExplicitThemeReachesRealDialog", appTheme);
+ owner.RequestedTheme = appTheme;
+ await Harness.Render();
+ owner.RequestedTheme = opposite;
+ await CheckTheme("ExplicitThemeWinsOverAncestor", appTheme);
+
+ state = state with { DialogTheme = opposite };
+ setState(state);
+ await CheckTheme("ExplicitThemeUpdatesWhileOpen", opposite);
+
+ state = state with { DialogTheme = ElementTheme.Default };
+ setState(state);
+ await Harness.Render();
+ H.Check("DialogTheme_ExplicitDefaultIsNotUnset",
+ ReferenceEquals(Current(), mounted) && Current().RequestedTheme == ElementTheme.Default);
+
+ state = state with { DialogTheme = null };
+ setState(state);
+ await CheckTheme("RemovingOverrideResumesFollowing", opposite);
+ owner.RequestedTheme = appTheme;
+ await CheckTheme("FollowingRemainsLiveAfterRemoval", appTheme);
+
+ state = state with { NativeOverride = true };
+ setState(state);
+ await CheckTheme("NativeSetterWins", opposite);
+ owner.RequestedTheme = opposite;
+ await Harness.Render();
+ owner.RequestedTheme = appTheme;
+ await CheckTheme("FollowingDoesNotOverwriteNativeSetter", opposite);
+
+ state = state with { NativeOverride = false };
+ setState(state);
+ await CheckTheme("RemovingNativeSetterResumesFollowing", appTheme);
+
+ state = state with { IsOpen = false };
+ setState(state);
+ H.Check("DialogTheme_Closes", await ContentDialogProbe.WaitForClosed(H, Title));
+ var closedTheme = mounted.RequestedTheme;
+ owner.RequestedTheme = opposite;
+ await Harness.Render();
+ H.Check("DialogTheme_CloseStopsFollowing", mounted.RequestedTheme == closedTheme,
+ $"Before ancestor change: {closedTheme}; after: {mounted.RequestedTheme}");
+
+ state = state with { IsOpen = true };
+ setState(state);
+ 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);
+ H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title));
+ var unmountedTheme = mounted.RequestedTheme;
+ anchor!.RequestedTheme = appTheme;
+ await Harness.Render();
+ H.Check("DialogTheme_UnmountStopsFollowing", mounted.RequestedTheme == unmountedTheme);
+ }
+ 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);
+
+ private sealed record ScenarioProps(
+ ScenarioState Initial,
+ Action> CaptureSetter,
+ Action CaptureAnchor);
+
+ 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,
+ };
+ 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.OnMountAdd(Props.CaptureAnchor) : 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..9c1ab434c
--- /dev/null
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs
@@ -0,0 +1,140 @@
+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 reference = await ReadNativeReference(requested);
+ 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 ? reference.ActualTheme : opposite,
+ explicitTheme ? 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 : 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}");
+ }
+ 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), "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/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs
index bcff429bf..59f4226d5 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
@@ -2450,6 +2454,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),
From 9a2874cb3b3152e0f033f82b209e3b2b43d34605 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?kusutori=E3=82=AF=E3=82=B9=E3=81=A8=E3=82=8A?=
<100983934+kusutori@users.noreply.github.com>
Date: Fri, 23 Oct 2026 19:42:14 +0800
Subject: [PATCH 2/2] Fix dialog setter ownership, inherited Default and
initial content theme
---
.../templates/dialogs-and-flyouts.md.dt | 4 +-
docs/guide/dialogs-and-flyouts.md | 4 +-
src/Reactor/Core/Reconciler.cs | 22 +++--
.../V1Protocol/ContentDialogThemeBinding.cs | 21 ++++-
.../Core/V1Protocol/OverlayLifecycle.cs | 8 +-
.../Fixtures/ContentDialogThemeFixture.cs | 92 +++++++++++++------
.../ContentDialogThemeOpenMatrixFixture.cs | 19 +++-
7 files changed, 119 insertions(+), 51 deletions(-)
diff --git a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt
index a5735f42f..66195e159 100644
--- a/docs/_pipeline/templates/dialogs-and-flyouts.md.dt
+++ b/docs/_pipeline/templates/dialogs-and-flyouts.md.dt
@@ -108,8 +108,8 @@ 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 resumes following the owner. An explicit
-`ElementTheme.Default` is still a native theme request, not an absent override.
+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
diff --git a/docs/guide/dialogs-and-flyouts.md b/docs/guide/dialogs-and-flyouts.md
index cd981bd5a..a060e9cd5 100644
--- a/docs/guide/dialogs-and-flyouts.md
+++ b/docs/guide/dialogs-and-flyouts.md
@@ -116,8 +116,8 @@ 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 resumes following the owner. An explicit
-`ElementTheme.Default` is still a native theme request, not an absent override.
+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
diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs
index b54518ee1..0c0f53acf 100644
--- a/src/Reactor/Core/Reconciler.cs
+++ b/src/Reactor/Core/Reconciler.cs
@@ -784,14 +784,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
index 087bfa39f..79208443a 100644
--- a/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
+++ b/src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
@@ -25,14 +25,31 @@ internal ContentDialogThemeBinding(FrameworkElement anchor, WinUI.ContentDialog
internal void Apply(ContentDialogElement element)
{
- _requestedTheme = element.Modifiers?.RequestedTheme;
+ 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 ObserveSetters() => _setterOverride = _dialog.RequestedTheme != _appliedTheme;
+ internal void ApplySetters(ContentDialogElement element)
+ {
+ // 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)
{
diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
index b5e75b9ff..316814f9e 100644
--- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
+++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
@@ -114,8 +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.ObserveSetters();
+ live.Theme.ApplySetters(n);
return null;
}
@@ -265,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,
@@ -293,9 +291,9 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi
try
{
theme.Apply(cdEl);
+ dialog.Content = reconciler.MountDialogContent(anchor, cdEl.Content, requestRerender, dialog.RequestedTheme);
// ApplySetters last so caller .Set(...) wins (including theme/XamlRoot).
- Reconciler.ApplySetters(cdEl.Setters, dialog);
- theme.ObserveSetters();
+ theme.ApplySetters(cdEl);
winUiResult = await dialog.ShowAsync();
}
finally
diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs
index 3dc772ccd..31e4555b8 100644
--- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeFixture.cs
@@ -17,10 +17,9 @@ public override async Task RunAsync()
var opposite = appTheme == ElementTheme.Dark ? ElementTheme.Light : ElementTheme.Dark;
var state = new ScenarioState(opposite);
Action? setState = null;
- FrameworkElement? anchor = null;
var host = H.CreateHost();
host.Mount(_ => Component(new(
- state, setter => setState = setter, element => anchor = element)));
+ 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);
@@ -36,6 +35,7 @@ public override async Task RunAsync()
state = state with { ParentTheme = appTheme };
setState!(state);
+ await Harness.Render();
await CheckTheme("FollowsParentRender", appTheme);
var owner = H.FindControl(panel =>
@@ -54,7 +54,8 @@ public override async Task RunAsync()
await CheckTheme("FollowsNativeAncestorChange", opposite);
state = state with { DialogTheme = appTheme };
- setState(state);
+ setState!(state);
+ await Harness.Render();
await CheckTheme("ExplicitThemeReachesRealDialog", appTheme);
owner.RequestedTheme = appTheme;
await Harness.Render();
@@ -62,44 +63,83 @@ public override async Task RunAsync()
await CheckTheme("ExplicitThemeWinsOverAncestor", appTheme);
state = state with { DialogTheme = opposite };
- setState(state);
+ setState!(state);
+ await Harness.Render();
await CheckTheme("ExplicitThemeUpdatesWhileOpen", opposite);
state = state with { DialogTheme = ElementTheme.Default };
setState(state);
await Harness.Render();
- H.Check("DialogTheme_ExplicitDefaultIsNotUnset",
- ReferenceEquals(Current(), mounted) && Current().RequestedTheme == ElementTheme.Default);
+ await CheckTheme("ExplicitDefaultFollowsOwner", opposite);
+ owner.RequestedTheme = appTheme;
+ await CheckTheme("ExplicitDefaultFollowsNativeChange", appTheme);
+ owner.RequestedTheme = opposite;
state = state with { DialogTheme = null };
- setState(state);
+ setState!(state);
+ await Harness.Render();
await CheckTheme("RemovingOverrideResumesFollowing", opposite);
owner.RequestedTheme = appTheme;
await CheckTheme("FollowingRemainsLiveAfterRemoval", appTheme);
state = state with { NativeOverride = true };
- setState(state);
+ setState!(state);
+ await Harness.Render();
await CheckTheme("NativeSetterWins", opposite);
owner.RequestedTheme = opposite;
await Harness.Render();
owner.RequestedTheme = appTheme;
await CheckTheme("FollowingDoesNotOverwriteNativeSetter", opposite);
- state = state with { NativeOverride = false };
- setState(state);
+ // 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);
- state = state with { IsOpen = false };
- setState(state);
+ 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));
- var closedTheme = mounted.RequestedTheme;
- owner.RequestedTheme = opposite;
- await Harness.Render();
- H.Check("DialogTheme_CloseStopsFollowing", mounted.RequestedTheme == closedTheme,
- $"Before ancestor change: {closedTheme}; after: {mounted.RequestedTheme}");
state = state with { IsOpen = true };
- setState(state);
+ 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);
@@ -114,12 +154,9 @@ public override async Task RunAsync()
await CheckTheme("ReopenUsesCurrentOwnerTheme", opposite);
state = state with { IncludeDialog = false };
- setState(state);
- H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title));
- var unmountedTheme = mounted.RequestedTheme;
- anchor!.RequestedTheme = appTheme;
+ setState!(state);
await Harness.Render();
- H.Check("DialogTheme_UnmountStopsFollowing", mounted.RequestedTheme == unmountedTheme);
+ H.Check("DialogTheme_UnmountCloses", await ContentDialogProbe.WaitForClosed(H, Title));
}
finally
{
@@ -151,12 +188,12 @@ private sealed record ScenarioState(
ElementTheme? DialogTheme = null,
bool IsOpen = true,
bool IncludeDialog = true,
- bool NativeOverride = false);
+ bool NativeOverride = false,
+ bool ButtonEnabled = true);
private sealed record ScenarioProps(
ScenarioState Initial,
- Action> CaptureSetter,
- Action CaptureAnchor);
+ Action> CaptureSetter);
private sealed class Scenario : Component
{
@@ -168,6 +205,7 @@ public override Element Render()
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)
@@ -178,7 +216,7 @@ public override Element Render()
}
return VStack(
TextBlock("Owner"),
- state.IncludeDialog ? dialog.OnMountAdd(Props.CaptureAnchor) : null)
+ 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
index 9c1ab434c..949c1b858 100644
--- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs
+++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ContentDialogThemeOpenMatrixFixture.cs
@@ -26,7 +26,10 @@ public override async Task RunAsync()
// 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 reference = await ReadNativeReference(requested);
+ 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 })
{
@@ -38,8 +41,8 @@ public override async Task RunAsync()
state,
// The nearest owner disagrees with the outer root, so using
// the window root instead of the declaration site is wrong.
- explicitTheme ? reference.ActualTheme : opposite,
- explicitTheme ? opposite : reference.ActualTheme,
+ explicitTheme && requested != ElementTheme.Default ? reference.ActualTheme : opposite,
+ explicitTheme && requested != ElementTheme.Default ? opposite : reference.ActualTheme,
explicitTheme ? requested : null,
setter => setState = setter)));
if (!openAtMount)
@@ -60,7 +63,7 @@ public override async Task RunAsync()
// prove the real, visible dialog received the theme.
var current = ContentDialogProbe.FindOpen(H, Title)!;
var text = ContentDialogProbe.FindText(current, ContentText);
- var expectedRequest = explicitTheme ? requested : reference.ActualTheme;
+ 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}");
@@ -71,6 +74,10 @@ public override async Task RunAsync()
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
{
@@ -132,7 +139,9 @@ public override Element Render()
{
var (state, setState) = UseState(Props.Initial);
Props.CaptureSetter(setState);
- var dialog = ContentDialog(Title, TextBlock(ContentText), "OK") with { IsOpen = state.IsOpen };
+ 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);
}