Skip to content

fix: stop DependencyPropertyDescriptor from keeping windows and controls alive (#438) - #439

Open
NotYoojun wants to merge 3 commits into
mainfrom
fix/438
Open

fix: stop DependencyPropertyDescriptor from keeping windows and controls alive (#438)#439
NotYoojun wants to merge 3 commits into
mainfrom
fix/438

Conversation

@NotYoojun

@NotYoojun NotYoojun commented Jul 26, 2026

Copy link
Copy Markdown
Member

Fixes #438.

PropertyDescriptor.AddValueChanged(component, handler) stores component -> handler in a dictionary that lives on the descriptor, and the descriptor behind DependencyPropertyDescriptor.FromProperty is cached per (property, type) for the lifetime of the process. Every AddValueChanged without a matching RemoveValueChanged therefore roots the component forever, and when the component is a FrameworkElement, its parent chain and window go with it.

The reported repro (a window with WindowHelper.UseModernWindowStyle="True", closed, then GC'd) had two independent roots, only one of which was in the report:

site subscribed on removed when
TitleBarControl.descriptor_WindowStyle/descriptor_ResizeMode the parent window only if the visual parent changes again (template swap). Closing the window leaves the template tree intact, so: never
WindowHelper.WindowResizeModeDescriptor the window, in SetWindowStyle only on the next SetWindowStyle(window) call, which never comes: never

Four more sites had the same pattern, on themselves or on an ancestor. Each one transitively roots the window it lives in:

  • InfoBar: own Foreground
  • SettingsCard: own IsPressed/IsMouseOver
  • MessageBox: own WindowHelper.SystemBackdropType
  • AnimatedIcon: an ancestor's AnimatedIcon.State, re-subscribed on every load with a lambda that was impossible to remove

PopupPositioner already pairs add/remove and is untouched.

Change

Two shapes, depending on who owns the property:

  • Watching own properties (InfoBar, SettingsCard, MessageBox): drop the descriptor entirely and override OnPropertyChanged, comparing e.Property. Same notifications, nothing to unsubscribe, less code.

  • Watching another object (TitleBarControl, WindowHelper, AnimatedIcon): keep the descriptor, but release the handler once the subscription is no longer needed, Window.Closed for the two window-level roots, Unloaded (and before re-subscribing) for AnimatedIcon. AnimatedIcon's handler is now stored in a field so it can actually be removed.

Two adjacent bugs in WindowHelper.SetWindowStyle came along:

  • The ActualThemeChanged handler and the constructed fresh on every call, so RemoveActualThemeChangedHandler/Loaded -= never matched anything and each call piled on another subscription. Both are now static methods that take the window from sender.
  • TitleBarControl.UpdateButtonActualAvailabilities() is public and dereferenced _parentWindow unconditionally; it
    now returns early when there is no parent wi

Gallery: TypographyPage

The same audit turned up two non-descriptor roots on this page, both created in the constructor and never undone: a subscription to the ThemeManager.Current.ActualApplicationThemeChanged singleton, and a 200ms DispatcherTimer that was started and never stopped.

The timer existed because the header image sits inside the ControlExample, and the toggle theme button sets RequestedTheme on the example content and its container rather than on the page, so no page-level theme notification fired and the light/dark image never swapped (hence the //FAILED TRIALS comment). The workaround compared the page's theme against the ControlExample's on every tick, which also meant the PNG was re-decoded five times a second for as long as a toggled theme stayed in effect.

ThemeManager keeps ActualTheme in sync on every element of the tree, so one ActualThemeChanged handler on the image itself covers the toggle and application-level theme changes alike. That replaces the timer, the multi-level theme detection, the singleton subscription, the RequestedTheme property override and a deferred one-shot refresh: -157 lines.

⚠️ BREAKING CHANGE: MessageBox.SystemBackdropTypeProperty_Descriptor (public static field) is removed. It existed only to host the leaking subscription this PR deletes, and calling AddValueChanged on it is exactly what keeps a MessageBox alive forever.

The header image sits inside the ControlExample, and the toggle theme button sets
RequestedTheme on the example content and its container rather than on the page, so
none of the page level theme notifications fired and the light/dark image never
swapped. The page worked around that with a 200ms DispatcherTimer comparing the
page's theme against the ControlExample's on every tick, which also meant
UpdateTypographyImage ran five times a second, re-decoding the PNG each time, for
as long as a toggled theme stayed in effect.

ThemeManager keeps ActualTheme in sync on every element of the tree, so a single
ActualThemeChanged handler on the image itself covers the toggle and application
level theme changes alike. It replaces the timer, the multi-level theme detection,
the singleton subscription, the RequestedTheme property override and the deferred
one-shot refresh.
Copilot AI review requested due to automatic review settings July 26, 2026 06:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak when using WindowHelper.UseModernWindowStyle="True"

2 participants