Visitar URL original
fix: Stop shared dependency objects retaining discarded elements and their DataContext (6.8 port of #25102) by mikernet · Pull Request #25839 · unoplatform/uno · GitHub
Skip to content

fix: Stop shared dependency objects retaining discarded elements and their DataContext (6.8 port of #25102) - #25839

Open
mikernet wants to merge 5 commits into
unoplatform:servicing/6.8from
mikernet:dev/mikernet/shared-dependency-objects-25099-6.8
Open

mikernet wants to merge 5 commits into
unoplatform:servicing/6.8from
mikernet:dev/mikernet/shared-dependency-objects-25099-6.8

Conversation

@mikernet

@mikernet mikernet commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Issue: Related to #25099

6.8 port of #25102 to servicing/6.8, in sync with its current head (0efaf28), which was reworked on top of #26123. That PR targets master and is still under review; #25099 stays open until it merges. If the review there changes the fix again, this port will follow.

PR Type:

🐞 Bugfix

What changed? 🚀

Dependency objects that are shared between elements (brushes, transforms, image sources, InputScopes, transitions, ...) kept the last element they were used by, or its DataContext, alive. Through that DataContext they could also keep the whole page alive after navigation. #25099 lists the reference chains.

servicing/6.8 already has two of the fixes: the weak single-parent association of a shareable value, with the guarded DataContext clear on release (#26142, the backport of #26123), and the ToolTip release (#26098, the backport of #25137 and #26096). This PR, rebased on current servicing/6.8, ports what #25102 still adds on top of them. Five commits:

  1. fix(transform): Stop shared transforms rooting discarded elements: fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102's 86e5b66, with its follow-up 880cfd2 (popup test portable to WinUI) folded in. Same code as fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102.
  2. fix(transform): Register brush, geometry and group handlers weakly: fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102's c331145, plus the two native partials that only 6.x has (Transform.Android.cs and Transform.Apple.cs), which now notify through the weak handlers instead of the removed Changed event. Same code as fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102 otherwise.
  3. fix(dependencyobject): Hold a value's inherited DataContext weakly: the 6.x version of fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102's be0c5c1, explained below. Unchanged from the previous version of this PR.
  4. fix(dependencyobject): Drop a collected parent's DataContext: fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102's 0efaf28, adapted to how 6.8 pushes a DataContext, explained below.
  5. fix(dependencyobject): Read an attached owner's DataContext (b4bacab): 6.8 only. It fixes a throw in commit 4's DataContext comparison, and the same throw in the guarded clear on release that servicing/6.8 already has from fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142, explained below.

Dropped from the previous version of this PR, because servicing/6.8 has them now or because #25102 dropped them: the ToolTipService commit and its test, the weak _associatedParentRef and the ALC sweep and comment changes that went with it, the Given_CollectibleAlcAssociations rewrite, the rule that a collected parent counts as a second parent (and SingleParentNonFE_Parent_Collected_Then_Next_Parent_Counts_As_Second), and the unconditional DataContext clear in UnassociateParent (and SingleParentNonFE_Parent_Removes_Value_Then_Next_Parent_Starts_Clean). The guarded clear in UpdateChildBindable from #26142 is now the only clear on release.

Commit 3: why 6.8 needs a different DataContext half

On master, #23547 removed DataContext from every object that isn't a FrameworkElement. Such an object keeps the DataContext its element passes down in one plain field, and #25102 only had to make that field a weak reference.

6.8 doesn't have #23547, and can't take it, because it removes public API. On 6.8 every dependency object still has a DataContext property, and the value it inherits from its element is stored like any other property value, with a strong reference. So without commit 3, a shared value still keeps the view model of the last element that used it:

  • a shareable value (a brush, for instance) used by a single element that is then discarded: nothing tells the brush that the element is gone, and fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142's weak association doesn't release the DataContext;
  • any other value, such as a shared InputScope or the transitions in a shared TransitionCollection, which keeps the last view model pushed into it, however many elements use it.

It never grows (at most one view model per shared value), but the view model can reference its page.

Commit 3 makes only the inherited (Inheritance precedence) DataContext of objects that aren't UIElements non-owning:

  • DependencyObjectStore decides once per object (originalObject is not UIElement) and tells DependencyPropertyDetailsCollection, which applies it to the DataContext property only.
  • DependencyPropertyDetails keeps that value in one weak handle per object, of a private type so that no value set by user code can be taken for it. The handle is created with the first non-null value, and again after the value has been cleared; a change only retargets it, so it allocates nothing and takes no lock. Clearing the value frees the handle right away. GetEffectiveValue, GetBaseValue and GetInheritedValue unwrap it.
  • Once an object's DataContext has been animated, including by a VisualState setter that targets it, that object keeps its inherited DataContext strongly for the rest of its life, as all objects did before. DataContext is never coerced.

What stays the same:

  • UIElements keep their DataContext strongly. The element that passed a DataContext down keeps it alive for as long as it lives, so bindings on its brushes, row definitions, runs, triggers and so on keep working. Those bindings already held their source weakly.
  • A DataContext set on the object itself stays strong, for example flyout.DataContext = viewModel in code, or DataContext="{Binding Sub}" on a brush.
  • The single-parent rules of fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142 don't change.

Visible differences, all the same as on master (where these objects have no DataContext property) and in WinUI (which has no DataContext on them):

  • Reading DataContext on a brush, transform or other object that isn't a UIElement can return null after a garbage collection, once nothing else holds that view model (the element that provided it no longer has it), even if other elements still use the object. No DataContextChanged event is raised.
  • A binding added to such an object after that point resolves against null.

No public API change.

Commit 4: a collected parent's DataContext

Since #26142, a shareable value whose single parent was collected without unassociating counts that parent as no parent, and the next owner becomes its single parent, as in WinUI, where a destroyed parent removes itself from its values (CDependencyObject::ReleaseImpl -> ResetReferencesFromChildren -> RemoveParent). Two things were left behind:

  • The expired slot was reused with the DataContext the dead parent had pushed in. On 6.8, an owner pushes its DataContext into a new value only when the assignment changes the property's precedence, so after a same-precedence replacement (an element that already had a local Background, for instance) the value's bindings kept resolving against the old view model whenever it was still alive. The weak storage of commit 3 doesn't help then.
  • The dead parent never released its bindable slot, so _bindableHolderCount stayed one too high, and fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142's guarded clear on release (which waits for the last holder) never fired again for that value: the next owner's DataContext stayed behind once that owner let go.

When AssociateParent reuses an expired slot, it now clears the inherited DataContext unless it already is the new owner's, and leaves a value with its own parent other than the new owner alone (the same guard as the DataContext-change push, ApplyChildrenBindable). ReleaseCollectedParent frees the slot and gives back the dead parent's holder count, there and in the ALC sweep's collected-parent branch.

The difference from #25102: there the DataContext is cleared, and master's on-attach push in UpdateChildBindable then hands over the new owner's DataContext if it isn't null. 6.8 has no on-attach push. Its precedence-change push (TryApplyDataContextOnPrecedenceChange) runs before UpdateChildBindable, so a plain clear would drop the DataContext that push just gave the value; that is why the clear skips a DataContext that already is the new owner's. A reused value now ends where a value used for the first time ends in the same assignment: with the new owner's DataContext after a precedence change, and with none after a same-precedence replacement, where 6.8 pushes nothing into a new value either (unchanged) until the owner's DataContext changes.

Commit 5: an owner whose values live in an attached store

Commit 4's comparison and #26142's guarded clear on release in UpdateChildBindable both read the owner's DataContext with GetValue(DependencyProperty). That overload checks the property's owner when FeatureConfiguration.DependencyProperty.ValidatePropertyOwnerOnReadWrite is on, which is the default in Debug builds and in any build with a debugger attached. A plain object owner such as TextHighlighter, which isn't a dependency object on 6.8, keeps its values in an attached store. That store's DataContext property is owned by AttachedDependencyObject, while the store's type is TextHighlighter, so the check threw "The Dependency Property [DataContext] is owned by [AttachedDependencyObject] and cannot be used on [TextHighlighter]".

TextBlock hits commit 4's read on every draw with a selection: it creates a TextHighlighter for its selection brush, and the previous highlighter has been collected by then. #26142's read throws when a highlighter's brush is replaced. These reads now go through the stores' DataContext details, as SetMentoredChild and RepropagateMentoredChildrenDataContext already do. The values read don't change; only the owner check is skipped.

#25102 and master's #26123 don't need this: on master only a FrameworkElement has a DataContext property, and these reads go through GetInheritedDataContextValue().

Known remaining gap: shared flyouts

A flyout that is shared (app-level or static) and attached through Button.Flyout, FlyoutBase.AttachedFlyout, or the flyout of a SplitButton or DropDownButton, still keeps the view model of the last element it was attached to. With commit 3 the flyout's own DataContext is weak, but 6.8 copies it into UIElements the flyout owns, and those hold it strongly:

  • Flyout sets it on its Content as a local value, whenever its DataContext changes, even if the flyout never opens;
  • MenuFlyout passes it to its items, which are UIElements;
  • FlyoutBase copies it onto its popup as a local value.

ContextFlyout is less exposed: its DataContext is cleared when its element unloads.

Master doesn't have this problem: it removed the popup copy, and #23547 changed how flyouts get their DataContext. Doing the same here would change 6.8's flyout DataContext behavior (content and items would no longer see the owner's DataContext while closed), which is out of scope for a servicing fix. This PR doesn't change it either way.

Performance

Commit 3 keeps one small object (32 bytes on 64-bit, holding a weak GC handle, with a finalizer) per object that receives a DataContext from its element. It is created with the first non-null value, and again after the value has been cleared (for example when the element of a ContextFlyout unloads, or when a shared value loses its only parent). Clearing frees it right away; otherwise its finalizer frees it when the object is collected. A DataContext change only retargets the handle: no allocation and no lock, so no extra garbage collections. Commit 4 only adds work when a value whose parent was collected is reused, and commit 5 makes the reads it touches skip the owner check; commits 1 and 2 don't touch the DataContext path.

Time for 1000 DataContext changes on a panel of 20 controls, with the same probe as in the previous round: each run takes the median of 9 timed rounds after warm-up, and each build had 6 runs in rotating order. This round was measured on Skia desktop only, at this head, in one Release build where a temporary local switch turned commit 3's weak storage off for the "without" runs, so both columns run the same code otherwise. Other builds were running on the machine, so a run's median swung by up to 2x; the lowest timed round over the 6 runs is the steadier number.

20 × without commit 3: median, lowest this PR: median, lowest
Border with an inline brush 3.3 ms, 2.4 ms 2.7 ms, 2.6 ms (+8%)
Button 30.5 ms, 25.7 ms 29.8 ms, 27.0 ms (+5%)
TextBox 108.2 ms, 94.1 ms 108.8 ms, 101.4 ms (+8%)

The medians are within the noise; the lowest rounds give +5% to +8%. The previous round, on the servicing/6.8 of that time and with little other load, measured +4% to +8% on Skia desktop (medians: Border 2.5 ms to 2.6 ms, Button 25.4 ms to 27.5 ms, TextBox 95.7 ms to 101.1 ms) and +4% to +8% on WebAssembly (Chrome, interpreter; medians: Border 55.5 ms to 59.8 ms, Button 578 ms to 600 ms, TextBox 1815 ms to 1950 ms; +2% to +8% across runs). I didn't measure WebAssembly again: commit 3 hasn't changed since, and the base changes since then (#26142 among them) are on code both builds share. What remains is the weak handle itself: reading or changing such a DataContext is a runtime call. Master pays the same kind of cost for its weak field. Page creation and teardown weren't measured separately; there, each affected object allocates its handle once and releases it once, as master does with its pooled handle.

Tests

From #25102:

  • Given_Transform: When_RenderTransform_Shared_By_Style_Then_Discarded_Elements_Collected, When_RenderTransform_Shared_By_Popup_Style_Then_Discarded_Popups_Collected, When_Projection_Shared_By_Style_Then_Discarded_Elements_Collected, When_Transform_Shared_By_Brushes_Then_Discarded_Brushes_Collected, When_Transform_Shared_By_Geometries_Then_Discarded_Elements_Collected, When_Transform_Shared_By_TransformGroups_Then_Discarded_Groups_Collected and the guard When_TransformGroup_Children_Cleared_Then_Removed_Child_Collected. Same file as in fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102.
  • Given_BindingMemoryLeak: When_BrushShared_SingleOwner_Removed_Then_Owner_And_ViewModel_Collected, When_TransitionsShared_Removed_Then_ViewModel_Collected and When_InputScopeShared_Removed_Then_ViewModel_Collected. 6.x declares Transitions on FrameworkElement, and not at all on the native mobile targets, so the transitions test uses FrameworkElement.TransitionsProperty and is compiled out there.
  • Given_UIElement.DataContext: SingleParentNonFE_Parent_Collected_Then_Next_Parent_Starts_Clean (commit 4) and the guard SharedNonFE_ObjectBinding_Subtree_Severed_Then_Bound_Value_Released (commit 3).

New for 6.x, in Given_UIElement.DataContext:

  • SingleParentNonFE_Parent_Collected_Then_Next_Parent_Gets_What_A_New_Value_Gets (commit 4), with a same-precedence replacement and with a precedence change: a bound brush whose first owner was collected while that owner's view model stays alive, assigned to a new owner that has a DataContext, never shows the dead owner's view model and ends with what a brush used for the first time gets in the same assignment (the new owner's DataContext after a precedence change, none after a same-precedence replacement).
  • SingleParentNonFE_Parent_Collected_Then_Next_Parent_Has_Attached_Store and SingleParentNonFE_Released_By_Owner_With_Attached_Store (commit 5): with the owner validation on, a TextHighlighter takes a brush whose first owner was collected (the brush must also drop the dead owner's DataContext), and replaces its brush, without throwing.
  • NonFE_Inherited_DataContext_Survives_GC_While_Owner_Alive, NonFE_Binding_Added_After_DataContext_Pushed_Then_Resolves, NonFE_DataContext_Bound_To_Sub_Then_Resolves_And_Updates and NonFE_Local_DataContext_Stays_Owned (commit 3, unchanged): they force garbage collections and check that objects that aren't UIElements, and their bindings, still resolve to the right view model, never to the weak handle or to nothing, and that a DataContext set on the object itself stays owned.

Validation

On Skia desktop (Release), at 97a2f17 (commit 4): Given_Transform, Given_BindingMemoryLeak, Given_UIElement (including the DataContext tests), Given_CollectibleAlcAssociations, Given_ResourceDictionary_DataContext, Given_ToolTip, Given_FrameworkElement_And_Leak, Given_SharedTemplate_DataContext, Given_PlainDP_SharedValue and Given_Flyout_PropertyPropagation: 700 test cases, 688 passed, 11 failed, 1 skipped. The 11 failures fail the same way on an untouched servicing/6.8 build on the same machine: 10 Given_UIElement layout-rounding tests at 125% scaling (ActualOffset, TransformToVisual, clip and nesting cases) and Given_FrameworkElement_And_Leak.When_Add_Remove(CommandBarFlyout_Leak).

At b4bacab (commit 5), on Skia desktop (Release), the same classes plus Given_TextBlock: 851 test cases, 838 passed, 13 failed, 4 skipped. 11 failures are the ones above. The other two are Given_TextBlock.When_Inlines_Transitively_Change (a pixel check) and When_IsTextSelectionEnabled_CRLF (a clipboard check, and other processes on the machine share the clipboard). They failed the same way on a 6.8 build without commits 4 and 5 on the same machine.

Red and green, on Skia desktop, with a temporary local switch (not committed) that turns off parts of the product change:

  • commit 4 off: SingleParentNonFE_Parent_Collected_Then_Next_Parent_Starts_Clean and the same-precedence row of SingleParentNonFE_Parent_Collected_Then_Next_Parent_Gets_What_A_New_Value_Gets fail: the brush keeps the dead owner's view model;
  • commit 4 with a plain clear, as on master: the precedence-change row fails: the brush loses the new owner's DataContext and falls back to its default color;
  • commit 3's weak storage off, commit 4 on: the three Given_BindingMemoryLeak DataContext tests fail, the commit 4 tests pass.

These two ran with an earlier form of commit 4 that set the new owner's DataContext instead of keeping it; its holder-count part and commit 3 are unchanged:

  • commit 3's weak storage and commit 4 off (the servicing/6.8 DataContext code): the three Given_BindingMemoryLeak DataContext tests and Starts_Clean fail; the other Given_BindingMemoryLeak and Given_UIElement DataContext tests pass;
  • commit 4 without the holder-count give-back: Starts_Clean fails at its last step.

Commit 5 off: both of its tests fail with the owner validation exception, the first in AssociateParent and the second in UpdateChildBindable's clear on release.

With the full change, all of them pass. The transform tests are unchanged from the previous round, where they failed without their commits. Commit 3's NonFE_* tests and SharedNonFE_ObjectBinding_Subtree_Severed_Then_Bound_Value_Released are guards: they pass with and without the change.

In Uno.UI.UnitTests (Release, net10.0), the only failures are 13 calendar tests (When_Gregorian_FixedDate* and When_DateTimeOffset_Is_Next_Day_If_Converted_To_Utc) that depend on the machine's time zone, as in the previous round.

Not run this round: Skia WebAssembly runtime tests and the native Uno.UI builds. Commits 1 to 3 are unchanged since the previous round, where they passed on WebAssembly and the native builds succeeded for net10.0-android, net10.0-ios26.0, net10.0-maccatalyst26.0, net10.0-tvos26.0, net9.0-android and net9.0-ios18.0. Commits 4 and 5 only change shared C# in DependencyObjectStore.

PR Checklist ✅

@mikernet
mikernet force-pushed the dev/mikernet/shared-dependency-objects-25099-6.8 branch from f9e8686 to 2d8b6f9 Compare October 29, 2026 22:43
@github-actions github-actions Bot added platform/android 🤖 Categorizes an issue or PR as relevant to the Android platform platform/ios 🍎 Categorizes an issue or PR as relevant to the iOS platform labels Oct 29, 2026
@mikernet

Copy link
Copy Markdown
Contributor Author

Force-pushed a fix to commit 4: it removed Transform.Changed, but the 6.x-only Transform.Android.cs and Transform.Apple.cs still raised it, so native Uno.UI didn't compile. They now notify through the weak handlers, as NotifyChanged does. The native Uno.UI builds succeed for net10.0-android, net10.0-ios26.0, net10.0-maccatalyst26.0, net10.0-tvos26.0, net9.0-android and net9.0-ios18.0. Commit 5 is unchanged; the description is updated.

@agneszitte

Copy link
Copy Markdown
Member

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@agneszitte

Copy link
Copy Markdown
Member

@MartinZikmund can you help follow up on this PR please

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Sample App stage site is ready! Visit it here:

@nventive-devops

Copy link
Copy Markdown
Contributor

The build 242669 did not find any UI Test snapshots differences.

@MartinZikmund MartinZikmund 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.

Reviewed by reading the code against this PR's head, servicing/6.8 and the WinUI C++ sources. I haven't built or run it.

I'd hold this until #25102 settles, since the port should follow whatever it ends up doing:

  1. The base has moved. servicing/6.8 already has the #26123 backport (5a5cce0 and follow-ups), and six files now conflict, including DependencyObjectStore.Binder.cs and ToolTipService.cs. "Same code as master" in the description is no longer accurate.
  2. Dead-parent rule. This port carries the same "a collected parent counts as a second parent" rule that conflicts with #26123. See my review on #25102, including the stale DataContext to handle when a collected parent's slot is reused.

The weak inherited storage in commit 5 looks right. The readers I traced in DependencyPropertyDetails unwrap the value, ModifiedValue stays strong, and the wrapper's handle is disposed when the value is unset.

An open question for servicing, rather than a defect: is this acceptable risk for a 6.8 fix? By the PR's own numbers, WebAssembly DataContext changes cost +2% to +8%. Each affected object gets a finalizable handle, public DataContext on non-UIElements can read null after a GC, and the shared-flyout leak remains.

NativeRenderTransformAdapter subscribed strongly to Transform.Changed and
only unsubscribed when the element's RenderTransform was replaced, so a
transform shared through a Style setter accumulated one handler per element
ever styled with it, keeping every discarded element (and its inherited
DataContext) alive. The adapter now registers through a weak registration
on the transform, so the transform never roots its subscribers.

unoplatform#25099
Transforms are multi-parent shareable, as in WinUI, so one transform can
outlive the brushes, geometries and TransformGroups that use it (a
resource referenced from a template, for instance). Each of them still
subscribed to the strong Transform.Changed event, so the shared
transform kept every discarded one alive, and through a geometry's
GeometryChanged handler also the Path it draws or the element it clips.
WinUI tracks those parents with non-owning pointers.

They now go through RegisterChanged and hold the registration, like the
render transform adapter, Popup and the projection owner. With no
strong subscriber left, the strong Changed events of Transform and
Projection are removed so the leak cannot come back through them.
TransformGroup now also handles the Reset that Children.Clear() raises
without the removed items, so it lets go of the cleared children.

The 6.x-only native partials (Transform.Android.cs, Transform.Apple.cs)
raised the removed Changed event too; they now notify through the weak
handlers, as NotifyChanged does.
A DependencyObject that isn't a UIElement, used as a property value,
keeps the DataContext its element pushes into it, so bindings added or
resumed on it later resolve against it. That value was held strongly,
so a long-lived shared value (a style setter value, a static brush, a
TransitionCollection or an InputScope) kept the DataContext of the last
element it was applied to, and with it that element's page, alive
after navigation.

Hold it weakly, as WinUI does: these objects have no DataContext there,
their bindings resolve through a non-owning inheritance-context mentor,
and the DataContext belongs to the mentor. Master holds its equivalent
cache weakly. 6.x stores that DataContext as a property value at
Inheritance precedence, so its DependencyPropertyDetails now keeps that
value in one weak handle per object, retargeted in place, and every
reader unwraps it. A DataContext set on the object itself, and one that
has been animated, stay strong.

unoplatform#25099
A shareable value whose single parent was collected without
unassociating takes the next owner as its single parent again, but kept
the DataContext the dead parent had pushed into it. On 6.x an owner
pushes its DataContext into a new value only when the assignment
changes the property's precedence, so after a same-precedence
replacement the value's bindings kept resolving against the old view
model whenever that view model was still alive, and the weak inherited
DataContext does not help then.

WinUI has no such state: a destroyed parent removes itself from its
values (CDependencyObject::ReleaseImpl -> ResetReferencesFromChildren),
which takes the inheritance context with it. When the expired slot is
reused, clear the inherited DataContext unless it already is the new
owner's (a precedence-changing assignment has pushed it by then), and
leave a value with its own parent alone, like ApplyChildrenBindable.
The reused value then ends where a value used for the first time would.

The dead parent never released its bindable slot either, so it kept
counting as a holder and the guarded clear on release, which waits for
the last holder, never fired again for that value: the next owner's
DataContext stayed behind once it let go. Give that slot back when the
expired association is freed, here and in the ALC sweep.

unoplatform#25099
@mikernet
mikernet force-pushed the dev/mikernet/shared-dependency-objects-25099-6.8 branch from 2d8b6f9 to 97a2f17 Compare November 13, 2026 04:24
@mikernet

Copy link
Copy Markdown
Contributor Author

I rebased onto current servicing/6.8 and brought the port in line with #25102's current head (0efaf28), so it no longer carries what #26142 and #26098 already backported. The PR is now four commits: the transform/projection registrations (da95ebd, ed4e5d5), the weak inherited DataContext (eea6fb2, unchanged) and the expired-slot handling (97a2f17).

  1. The base has moved. Dropped, since servicing/6.8 has them now: the ToolTipService commit and its test (fix: Release the current ToolTip when it closes (backport #25137, #26096) #26098), the weak _associatedParentRef, my ALC sweep and comment changes in DependencyObjectStore.alc.cs, Application.Alc.cs and ResourceDictionary.alc.cs, and the Given_CollectibleAlcAssociations rewrite (fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142). The description no longer says "same code as master". It says which commits match fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102 and where 6.8 differs: the two 6.x-only native Transform partials in commit 2, the DataContext storage in commit 3, and one step in commit 4 (below).

  2. Dead-parent rule. The port now keeps fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142's rule as is: a collected parent counts as no parent. SingleParentNonFE_Parent_Collected_Then_Next_Parent_Counts_As_Second is gone. I also dropped the unconditional clear in UnassociateParent (and its test, ..._Removes_Value_Then_Next_Parent_Starts_Clean), so fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142's guarded clear in UpdateChildBindable is the only clear on release. For the stale DataContext on a reused slot, 97a2f17 ports 0efaf28: when AssociateParent reuses an expired slot, it drops the DataContext the dead parent left, and ReleaseCollectedParent gives back the dead parent's _bindableHolderCount, also in the ALC sweep's collected-parent branch. Without that give-back, fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142's guarded clear never fired again for that value.

    One step differs from master. Master clears there, then its on-attach push in UpdateChildBindable hands over the new owner's DataContext if it isn't null. 6.8 has no on-attach push. Its precedence-change push (TryApplyDataContextOnPrecedenceChange) runs in InnerSetValue before the callbacks reach UpdateChildBindable, so a plain clear would drop the DataContext that push just gave the value. The port therefore clears only when the value's DataContext isn't already the new owner's, and leaves a value with its own parent other than the new owner alone (the same guard as the DataContext-change push, ApplyChildrenBindable). A reused value now ends like a value used for the first time in the same assignment: with the owner's DataContext after a precedence change, and with none after a same-precedence replacement, where 6.8 pushes nothing into a new value either (unchanged).

    Tests, on Skia desktop: SingleParentNonFE_Parent_Collected_Then_Next_Parent_Starts_Clean (from fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102) and the new SingleParentNonFE_Parent_Collected_Then_Next_Parent_Gets_What_A_New_Value_Gets (same-precedence and precedence-change rows, each compared with a brush used for the first time). With commit 4 switched off in a local build, Starts_Clean and the same-precedence row fail: the brush keeps the dead owner's red. With a plain clear, the precedence-change row fails: the brush loses the new owner's DataContext. Without the holder-count give-back, Starts_Clean fails at its last step.

    The weak inherited storage is unchanged from the version you reviewed.

  3. Servicing risk. The facts, for whoever decides:

    • Commits 1 and 2 are the same code as fix: Stop shared dependency objects retaining discarded elements and their DataContext #25102 (plus the two native partials), and only change how transforms, projections, brushes, geometries and groups subscribe to each other.
    • Commit 3 is where the cost is. I measured 1000 DataContext changes on 20 controls again on Skia desktop at this head, in one build with a local switch for commit 3's weak storage. The machine was loaded and run medians swung by up to 2x, so the medians don't separate. The lowest timed rounds give +5% (Button) to +8% (Border, TextBox), in line with the previous round's medians (+4% to +8%). I didn't measure WebAssembly again. Commit 3 hasn't changed since the +4% to +8% measured there (medians; +2% to +8% across runs), and the base changes since then (fix: Stop shared values on plain DPs retaining their owners (backport #26123) #26142 among them) are on code both builds share.
    • Each object that isn't a UIElement and receives a non-null DataContext from its element gets one 32-byte object holding a weak GC handle, with a finalizer. It is freed when that DataContext is cleared, otherwise by the finalizer when the object is collected.
    • DataContext on such an object (a public property on 6.8) can read null after a GC once nothing else holds that view model, even while other elements use the object. No DataContextChanged is raised. Master and WinUI have no such property.
    • The shared-flyout retention stays as it is: this PR doesn't change it.
    • Commit 4 doesn't depend on commit 3: with commit 3's weak storage switched off in a local build, the commit 4 tests pass. Commits 1, 2 and 4 could go in without commit 3. The leaks commit 3's three Given_BindingMemoryLeak tests cover (a shared brush, TransitionCollection or InputScope keeping the last owner's view model) would then remain on 6.8.

@mergify

mergify Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue). If it does not match the queue conditions yet, it enters the queue once it does.

  • Queue this pull request

mikernet added a commit to Singulink/uno that referenced this pull request Nov 13, 2026
…alue (port)

Port of the review fix in unoplatform#25839 (commit 97a2f17), on
top of this build's port fd40bf9.

On 6.x an owner pushes its DataContext into a new value
(TryApplyDataContextOnPrecedenceChange) before the value is associated
with it, and only when the assignment changes the property's
precedence. The plain clear fd40bf9 added where a collected parent's
slot is reused therefore wiped the DataContext a precedence-changing
assignment had just pushed. The clear now keeps the inherited
DataContext when it already is the new owner's, so a reused value ends
where a value used for the first time would: the owner's DataContext
after a precedence change, none after a same-precedence replacement.
mikernet added a commit to Singulink/uno that referenced this pull request Nov 13, 2026
Fix for 7546379, the port of unoplatform#25839's review fix
(97a2f17), which upstream gets in the same form.

Where a collected parent's slot is reused, the value now compares its
DataContext with the new owner's, read with GetValue(DependencyProperty).
That overload checks the property's owner when
ValidatePropertyOwnerOnReadWrite is on (Debug builds, or any build with a
debugger attached). A POCO owner such as TextHighlighter keeps its values
in an attached store, whose DataContext property is owned by
AttachedDependencyObject while the store's type is the POCO's, so the
check threw "The Dependency Property [DataContext] is owned by
[AttachedDependencyObject] and cannot be used on [TextHighlighter]".
TextBlock hits it on every draw with a selection: it creates a
TextHighlighter for the selection brush, whose previous highlighter has
been collected. Both reads now go through the stores' DataContext
details, as SetMentoredChild and RepropagateMentoredChildrenDataContext
already do.
The expired-slot step in AssociateParent (previous commit) and the
guarded release clear in UpdateChildBindable (unoplatform#26142) compare a value's
DataContext with its owner's, read with GetValue(DependencyProperty).
That overload checks the property's owner when
ValidatePropertyOwnerOnReadWrite is on (Debug builds, or any build with a
debugger attached). A POCO owner such as TextHighlighter keeps its values
in an attached store, whose DataContext property is owned by
AttachedDependencyObject while the store's type is the POCO's, so the
check threw "The Dependency Property [DataContext] is owned by
[AttachedDependencyObject] and cannot be used on [TextHighlighter]".
TextBlock hits the first one on every draw with a selection: it creates
a TextHighlighter for its selection brush, whose previous highlighter
has been collected. The second one fires when a highlighter's brush is
replaced. All these reads now go through the stores' DataContext
details, as SetMentoredChild and RepropagateMentoredChildrenDataContext
already do.
@mikernet

Copy link
Copy Markdown
Contributor Author

servicing/6.8's #26142 backport reads the owner's DataContext with GetValue(DependencyProperty) in its guarded clear on release. That throws under ValidatePropertyOwnerOnReadWrite (Debug builds, or a debugger attached) when the owner is an attached store, such as TextHighlighter's; TextBlock hits the same throw through this PR's own read on every draw with a selection.
b4bacab fixes both reads (they now go through the stores' DataContext details) and adds a test for each that fails without it.
Master's #26123 isn't affected: it reads through GetInheritedDataContextValue().

mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Takes Uno's servicing/6.8 from 6951c2c up to 5254ace (42 pull
requests). Conflicts, by area:

IME and TextBox (unoplatform#25984, backport of unoplatform#25804; unoplatform#25952, backport of unoplatform#25619;
unoplatform#25976, backport of unoplatform#25818)
- Takes upstream's composition design (unoplatform#25804): the browser's input is
  the source of truth for the text, compositions included. This drops
  the composition parts of the unoplatform#24502 port (90746b8): its
  TypeScript composition handling, the StartIndex/TextChangePending
  event arguments, the IsReadOnly forwarding to the hidden input, and
  its Given_TextBox.BrowserSoftKeyboard tests.
- Keeps the non-IME browser fixes on top of it, in the form their
  master pull requests have on unoplatform#25804: Enter raised once per press and
  tracked by key code (unoplatform#25174, with Given_TextBox.BrowserEnterKey from
  master in place of the Enter tests that lived in the dropped file),
  the iOS refocus on tap (unoplatform#25185), pastes without text and isIOS from
  BrowserInputHelper.
- Moves the unoplatform#26005 MaxLength fixes onto unoplatform#25804's native input path, as
  master has them: a refused browser edit keeps the TextBox's selection,
  TextBoxView.UpdateTextFromNative reports whether the edit went in, and
  the IME keeps its range on the applied text (master be85699, which
  d226897 had ported onto the unoplatform#24502 IME). Adds that commit's browser
  and platform-applied IME tests.
- TextBox.pointers.skia.cs: upstream's file (unoplatform#25619 iOS touch editing,
  unoplatform#25818, unoplatform#25804), with the unoplatform#25126 port (the hold acts when it starts,
  a touch right-tap keeps the caret) in the form master's unoplatform#25126 branch
  has it on unoplatform#25619, including focusing before the iOS hold timer acts
  and its iOS not-focusable test.

Pointers (unoplatform#25976, backport of unoplatform#25818)
- Tapped and RightTapped are raised once the PointerReleased bubbled
  (unoplatform#25818), with the unoplatform#25732 port's window coordinates (2-argument
  routed event args, RawPosition for ContextRequested), as master's
  unoplatform#25732 branch has them.
- The release wrapper of unoplatform#25818 keeps the unoplatform#25126 port's
  StopContextMenuTimer at the start of the release.

DependencyObject (unoplatform#26142, backport of unoplatform#26123)
- Takes upstream, then the code of unoplatform#25839 (97a2f17), which replaces
  this branch's unoplatform#25102 ports (800247f, be41da6, fd40bf9,
  7546379). DependencyObjectStore.Binder.cs, .alc.cs,
  Application.Alc.cs, ResourceDictionary.alc.cs and their tests now
  match unoplatform#25839; this drops 800247f's own
  SingleParentNonFE_Parent_Removes_Value_Then_Next_Parent_Starts_Clean
  test, which unoplatform#25839 doesn't have.

Imaging (unoplatform#25597, unoplatform#25915, unoplatform#25911, unoplatform#25897)
- Keeps this branch's side: it has the later forms of these fixes
  (unoplatform#25124, unoplatform#25559, unoplatform#25633 and unoplatform#26370 ports), where upstream has the
  6.8-only unoplatform#25597 design and earlier snapshots. Upstream's shared
  download, IgnoreImageCache and failed-load fixes are covered by
  SharedImageDecode. Adds upstream's When_Source_Outlives_Brushes test.

ToolTip (unoplatform#26098, backport of unoplatform#25137 and unoplatform#26096)
- Keeps this branch's unoplatform#25137 port and adds unoplatform#26096 in master's form: the
  service releases only a ToolTip it opened, and leaves one the app
  opened during the show delay to the app. Keeps both sides' tests.

Flyout (unoplatform#26062, backport of unoplatform#26060)
- This branch had the same commits already; keeps the "Keep
  non-resizable flyouts in window" port and its tests.

Also removes a helper pair in Given_AccessibleTextBox that both sides
added.
mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Brings in the hotfix for the DataContext reads of an attached owner.
DependencyObjectStore.Binder.cs and Given_UIElement.DataContext.cs take
the form of unoplatform#25839 at b4bacab, which also covers the
release clear that servicing/6.8 added in UpdateChildBindable and adds
SingleParentNonFE_Released_By_Owner_With_Attached_Store.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform/android 🤖 Categorizes an issue or PR as relevant to the Android platform platform/ios 🍎 Categorizes an issue or PR as relevant to the iOS platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants