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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/_pipeline/templates/dialogs-and-flyouts.md.dt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/guide/dialogs-and-flyouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 14 additions & 8 deletions src/Reactor/Core/Reconciler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -820,14 +820,20 @@ private static bool ForwardsAnotherElement(UIElement control, Element element)
/// placeholder's published <c>owner=</c> is the owner its body was declared under, so it is
/// restored for the mount. Nested components still scope their own subtrees.
/// </summary>
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)
Expand Down
75 changes: 75 additions & 0 deletions src/Reactor/Core/V1Protocol/ContentDialogThemeBinding.cs
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;
}
}
39 changes: 26 additions & 13 deletions src/Reactor/Core/V1Protocol/OverlayLifecycle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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();

Copy link
Copy Markdown
Collaborator

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 WaitForClosed before probing, and by then the ShowAsync finally (line 306) has already disposed the binding. So this early Dispose() and the widened if (!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.

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.
Expand All @@ -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;
}

Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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
Expand All @@ -282,14 +290,19 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi
WinUI.ContentDialogResult winUiResult;
try
{
theme.Apply(cdEl);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content ThemeRefs on a mount-time open.

When IsOpen is true on the first render, the content is mounted from Loaded (MountDialogContent at line 268) with no ambient theme set. Its ThemeRef overrides therefore resolve against the app theme. Only then does this line move the chrome to the owner's theme. The mismatch lasts until the owner's next render.

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
Expand Down
Loading