Repository navigation
Fix ContentDialog theme forwarding and default inheritance #1389
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d57a53f
bd920d6
9a2874c
78efe60
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| using Microsoft.UI.Xaml; | ||
| using WinUI = Microsoft.UI.Xaml.Controls; | ||
|
|
||
| namespace Microsoft.UI.Reactor.Core.V1Protocol; | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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) | ||
| { | ||
| // 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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<FrameworkElement, WinUI.ContentDialog> s_liveDialogs = new(); | ||
| private sealed record LiveContentDialog(WinUI.ContentDialog Dialog, ContentDialogThemeBinding Theme); | ||
|
|
||
| private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable<FrameworkElement, LiveContentDialog> 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 | |
| /// </summary> | ||
| 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; | ||
| } | ||
|
|
||
| /// <summary>The dialog currently showing for <paramref name="anchor"/>, without taking it.</summary> | ||
| 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,18 +264,17 @@ 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, | ||
| // rather than capturing the mount-time element: the dialog re-renders | ||
| // 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,14 +290,19 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi | |
| WinUI.ContentDialogResult winUiResult; | ||
| try | ||
| { | ||
| theme.Apply(cdEl); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Content When Before this PR, chrome and content both used the app theme, so this mismatch is new. Could the content mount with the theme the binding is about to apply? |
||
| 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 | ||
| { | ||
| // 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Untested. The fixture waits in
WaitForClosedbefore probing, and by then theShowAsyncfinally(line 306) has already disposed the binding. So this earlyDispose()and the widenedif (!n.IsOpen)guard are never exercised.Could you add a step that, while the close is still in progress, re-renders with a changed prop and flips the owner's theme? Then assert the closing dialog stays unchanged.
To confirm the step works, delete this line or revert the guard to
!n.IsOpen && o.IsOpen, and check that the step turns red.