Skip to content

Release detached visuals from a stopped CompositingRenderer - #22369

Merged
MrJul merged 3 commits into
AvaloniaUI:mainfrom
NathanDrake2406:fix/22098-renderer-dirty-visuals-while-stopped
Oct 9, 2026
Merged

MrJul merged 3 commits into
AvaloniaUI:mainfrom
NathanDrake2406:fix/22098-renderer-dirty-visuals-while-stopped

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

What does the pull request do?

Stops CompositingRenderer from keeping references to visuals that leave the visual tree while rendering is stopped, for example while a window is minimized or hidden.

What is the current behavior?

Since #21192, CompositingRenderer.Update() returns early when its composition target is disabled. That update pass is the only place that clears _dirty and _recalculateChildren, apart from Dispose().

AddDirty and RecalculateChildren continue to add visuals while the renderer is stopped. Visual.OnDetachedFromVisualTreeCore also calls AddDirty(this) for every detached visual, after DetachFromCompositor(). Each visual that is removed from a minimized window therefore stays referenced by the renderer until the window is restored.

A window that replaces its content repeatedly while minimized, such as the lyrics view in #22098, retains every replaced visual and its data context. The issue reports _dirty.Count = 33,703 and _recalculateChildren.Count = 19,870 in a dump.

The early return is also in the 12.0 branch from 12.0.2, which matches the 12.0.4 report.

What is the updated/expected behavior with this PR?

Scenario Before After
Visual removed while rendering is stopped Stays in _dirty / _recalculateChildren until rendering resumes Removed from both sets when it detaches
Visual invalidated while stopped and still in the tree Stays queued, updated on StartRendering Unchanged
Visual removed while rendering is active Entry skipped and cleared by the next update Entry dropped at detach, update still queued

While stopped, the pending sets are now bounded by the size of the live visual tree.

How was the solution implemented (if it's not obvious)?

CompositingRenderer owns both sets, so the fix is in AddDirty. When the visual has no CompositionVisual, the renderer removes the visual from both sets instead of adding it. It still queues a composition update, as before.

This is safe because such an entry is already inert. UpdateCore skips dirty visuals without a CompositionVisual, and SynchronizeCompositionChildVisuals returns early for them. The only caller that passes a visual without a composition visual is the detach path, and a visual that attaches again reports itself dirty again.

Alternatives that were not chosen:

  • Run the update pass while stopped to drain the sets. This renders visuals of a stopped top level, which Do not update composition target when rendering is stopped #21192 removed on purpose.
  • Clear the sets in Stop() and resynchronize the whole tree in Start(). This drops pending updates for visuals that are still attached and costs a full tree walk.

Start() is unchanged. It re-queues an update when a set is non-empty. A removal through Panel.Children or a content change also queues the parent, which stays in the tree, so a restart still resumes the update.

The test needs to observe the pending state without a GC-dependent assertion, so this PR adds the internal HasPendingUpdateForUnitTests, next to the existing TriggerSceneInvalidatedForUnitTests.

Validation:

  • New test Visuals_Removed_While_Rendering_Stopped_Are_Not_Kept_By_Renderer fails on the first commit and passes on the second. It also checks that visuals still in the tree stay queued and are updated after StartRendering, and that hit testing no longer finds the removed visual.
  • Avalonia.Base.UnitTests, by class: CompositorLifetimeTests (2 passed), CompositorInvalidationTests (6 passed), CompositorHitTestingTests (58 passed), VisualTests (33 passed).

Not covered: RecalculateChildren and the Start() condition are not changed. Memory behavior on a real minimized window was not measured; the test asserts the renderer state directly.

Checklist

Breaking changes

None. The change is internal to CompositingRenderer.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #22098

A stopped CompositingRenderer skips its update pass, so nothing drains the
pending visuals. A visual that leaves the tree in that state must not stay
referenced by the renderer. Visuals that remain in the tree must stay queued
until rendering resumes.

The test fails: the renderer still references the removed visuals.

HasPendingUpdateForUnitTests exposes the pending state so that the test does
not depend on garbage collection.
A minimized or hidden window stops its renderer. A stopped CompositingRenderer
skips its update pass, and that pass is the only place that clears the dirty
and recalculate-children sets. Each visual that detached in that state stayed
in the sets, so a window that kept changing its content while minimized
retained every removed visual until rendering resumed.

A visual reports itself dirty one last time when it detaches, after it has
dropped its composition visual. The update pass ignores such an entry. Remove
the visual from both sets at that point instead of adding it, and still request
a composition update so that the detach is committed.

Visuals that remain in the tree stay queued, so a restart still resumes the
pending update.
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review October 2, 2026 02:06
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

NuGet packages

The packages for this pull request have been published (workflow run):

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The change makes sense now that we aren't processing the dirty collection when the renderer is stopped, UpdateCore was just skipping these visuals anyway.

LGTM!

@MrJul
MrJul added this pull request to the merge queue Oct 9, 2026
Merged via the queue into AvaloniaUI:main with commit 5705e63 Oct 9, 2026
12 checks passed
@NathanDrake2406
NathanDrake2406 deleted the fix/22098-renderer-dirty-visuals-while-stopped branch October 9, 2026 09:37
MrJul pushed a commit that referenced this pull request Oct 9, 2026
* test(rendering): cover visuals removed while rendering is stopped

A stopped CompositingRenderer skips its update pass, so nothing drains the
pending visuals. A visual that leaves the tree in that state must not stay
referenced by the renderer. Visuals that remain in the tree must stay queued
until rendering resumes.

The test fails: the renderer still references the removed visuals.

HasPendingUpdateForUnitTests exposes the pending state so that the test does
not depend on garbage collection.

* fix(rendering): release detached visuals from a stopped renderer

A minimized or hidden window stops its renderer. A stopped CompositingRenderer
skips its update pass, and that pass is the only place that clears the dirty
and recalculate-children sets. Each visual that detached in that state stayed
in the sets, so a window that kept changing its content while minimized
retained every removed visual until rendering resumed.

A visual reports itself dirty one last time when it detaches, after it has
dropped its composition visual. The update pass ignores such an entry. Remove
the visual from both sets at that point instead of adding it, and still request
a composition update so that the detach is committed.

Visuals that remain in the tree stay queued, so a restart still resumes the
pending update.

* chore: trim code comments
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CompositingRenderer retains removed visuals while a window is minimized because dirty queues are not drained

2 participants