Skip to content

Use weak events for FormattedString span subscriptions - #38408

Closed
wbv-profile wants to merge 1 commit into
dotnet:mainfrom
wbv-profile:fix/weak-events-formattedstring
Closed

wbv-profile wants to merge 1 commit into
dotnet:mainfrom
wbv-profile:fix/weak-events-formattedstring

Conversation

@wbv-profile

Copy link
Copy Markdown

Description of Change

FormattedString subscribes to PropertyChanged and PropertyChanging on every Span added to its collection, with plain CLR handlers. A Span that the app holds on to — or shares between two FormattedStrings — therefore roots the FormattedString, and through it the label or view that owns it.

The per-span subscriptions now go through the existing WeakNotifyPropertyChangedProxy and WeakNotifyPropertyChangingProxy in src/Controls/src/Core/Internals/WeakEventProxy.cs, and the type gains the finalizer those proxies require.

Two things are deliberately left alone:

  • The _spans collection subscription in the constructor. SpanCollection is private and owned by this FormattedString, so that handler cannot outlive it; a cycle between the two is collectable. It now carries a comment saying so, since the surrounding code is being changed for exactly the opposite reason.
  • Reset handling. SpanCollection.ClearItems is already overridden to raise a Remove carrying the removed items rather than a bare Reset, so unlike GradientBrush (Use weak events for GradientBrush gradient stop subscriptions #38397) there is no gap on Clear(). A test pins that behaviour.

A span that appears more than once in the collection is subscribed once and detached only when its last occurrence is gone, matching the approach taken in the earlier PRs in this series.

This is the same defect class as #38397, #38403 and #38405.

Tests

src/Controls/tests/Core.UnitTests/FormattedStringWeakEventTests.cs adds five tests: two leak tests (a retained span, and a span that was removed from the collection), plus three behavioural guards — a span still raising PropertyChanged(nameof(Spans)), a removed span no longer doing so, and Clear() detaching its spans.

Reverting the src/Controls/src/Core changes and re-running fails exactly one of them, RetainedSpanDoesNotRootFormattedString. The other four pass either way, and that is deliberate: the existing Remove path already unsubscribed correctly and ClearItems already raises Remove rather than Reset, so those four pin behaviour this change must not break rather than proving a fix. Only the retained-span-still-in-the-collection case was actually leaking.

With the fix all five pass and the full Controls.Core.UnitTests suite is green.

Issues Fixed

Fixes #36289
Fixes #38180

FormattedString subscribed to PropertyChanged and PropertyChanging on
every Span in its collection with plain CLR handlers. A Span the app
holds on to -- or shares between FormattedStrings -- therefore rooted
the FormattedString and the view that owned it.

Those per-span subscriptions now go through WeakNotifyPropertyChanged-
Proxy and WeakNotifyPropertyChangingProxy, and the type gains the
finalizer they require.

The _spans collection subscription in the constructor is deliberately
left alone: SpanCollection is private and owned by this instance, so
that handler cannot outlive it. It now carries a comment saying so,
since the surrounding code is changing for the opposite reason.

SpanCollection.ClearItems already raises Remove with the removed items
rather than a bare Reset, so unlike GradientBrush there is no gap on
Clear(); a test pins that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 38408

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 38408"

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label Sep 10, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Hey there @@wbv-profile! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Hey there @wbv-profile! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@kubaflo

kubaflo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Closing as overlapping with #36294, already merged into inflight/current as cacd981.

This fixes a real shared-Span retention problem, and I did not find a concrete correctness issue in the current diff. However, inflight already has the semantic fix, using per-occurrence subscription cleanup without adding a finalizer to the public FormattedString type. The event-delivery/removal tests here are useful candidates for a focused test-only follow-up, rather than a reason to merge a second implementation.

The issue remains visible on main; any deliberate backport should reuse the existing fix. Also, the red Actions agent job failed while downloading CLI checksums (curl connection reset), before framework code ran; that setup failure is not the reason for closing this PR.

@kubaflo kubaflo closed this Sep 10, 2026

This branch was previously deployed

1 inactive deployment
copilot-pat-pool — 0209d3ca Deployed Sep 10, 2026 by wbv-profile via conclusion #1843
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community ✨ Community Contribution

Projects

None yet

2 participants