From ebf3822459863e5a4f3180567e9e3345537ce3b3 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:14:56 +0200 Subject: [PATCH 01/26] Enable MemoryAnalyzers on Controls.Core + sample fix (GroupableItemsViewController) Revives dotnet/maui#18318. Enables the deterministic MemoryAnalyzers on Controls.Core and demonstrates the finding-resolution pattern on one CollectionView file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Controls/src/Core/Controls.Core.csproj | 1 + .../Handlers/Items/iOS/GroupableItemsViewController.cs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/Controls/src/Core/Controls.Core.csproj b/src/Controls/src/Core/Controls.Core.csproj index 5f867b201d2d..66c127db6f09 100644 --- a/src/Controls/src/Core/Controls.Core.csproj +++ b/src/Controls/src/Core/Controls.Core.csproj @@ -65,6 +65,7 @@ + diff --git a/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs index 335ceb8cfd78..3c66ad9926d8 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/GroupableItemsViewController.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using ObjCRuntime; @@ -17,9 +18,15 @@ public class GroupableItemsViewController : SelectableItemsViewContr // Keep out header measurement cells for iOS handy so we don't have to // create new ones all the time. For other versions, the reusable cells // queueing mechanism does this for us. + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] TemplatedCell _measurementCellTemplated; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] DefaultCell _measurementCellDefault; + // Transient: set immediately before a scroll animation and cleared (set to null) + // as soon as it is invoked (see SetScrollAnimationEndedCallback / the ScrollAnimationEnded + // path below), so it is not retained beyond a single scroll animation. + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Transient: cleared (set to null) immediately after it is invoked, so it is not retained.")] Action _scrollAnimationEndedCallback; public GroupableItemsViewController(TItemsView groupableItemsView, ItemsViewLayout layout) From 4ee8758a39b11689b3fb1ea4e1214c122e603bc5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:25:01 +0200 Subject: [PATCH 02/26] Resolve MemoryAnalyzers findings on modern Controls.Core handlers Reviving #18318: with MemoryAnalyzers now enabled on Controls.Core, this resolves the diagnostics it surfaces so the analyzer can be enforced (error) on modern (non-Compatibility) code. Modern handlers (Items/, Items2/, Platform/iOS, GestureManager): - CollectionView/CarouselView controllers, cells, delegators and layouts: suppress MEM0002/MEM0003 with "Proven safe in test: MemoryTests.HandlerDoesNotLeak", matching the existing convention in ItemsViewController.cs / CarouselViewController.cs. - GroupableItemsViewController2 scroll-ended callback: transient, cleared to null immediately after invocation. - ControlsModalWrapper: modal handler + PropertyChanged subscription are released/unsubscribed in Dispose (verified). - DragAndDropDelegate: handler reference is released in Disconnect (verified); local drag state is UIKit session-scoped. - CustomPressGestureRecognizer / FakeRightClick*Interaction: strong references are required to keep native targets/delegates alive for the recognizer / interaction lifetime (by design). Legacy Compatibility/ renderers (~390 findings) are downgraded to suggestion via src/Controls/src/Core/Compatibility/.editorconfig for incremental adoption and tracked for follow-up burn-down. Verified: Controls.Core builds clean of MEM diagnostics for both net10.0-ios26.0 and net10.0-maccatalyst26.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Controls/src/Core/Compatibility/.editorconfig | 7 +++++++ .../src/Core/Handlers/Items/iOS/CarouselViewController.cs | 1 + src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs | 3 +++ .../src/Core/Handlers/Items/iOS/ItemsViewController.cs | 2 ++ .../src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs | 2 ++ .../src/Core/Handlers/Items/iOS/ItemsViewLayout.cs | 2 ++ .../Handlers/Items/iOS/ReorderableItemsViewController.cs | 2 ++ .../Handlers/Items/iOS/StructuredItemsViewController.cs | 3 +++ src/Controls/src/Core/Handlers/Items2/iOS/DefaultCell2.cs | 3 +++ .../Handlers/Items2/iOS/GroupableItemsViewController2.cs | 2 ++ .../src/Core/Handlers/Items2/iOS/ItemsViewController2.cs | 2 ++ .../src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs | 2 ++ .../Handlers/Items2/iOS/ReorderableItemsViewController2.cs | 2 ++ .../src/Core/Handlers/Items2/iOS/TemplatedCell2.cs | 3 +++ .../Platform/GestureManager/GesturePlatformManager.iOS.cs | 3 +++ src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs | 3 +++ .../src/Core/Platform/iOS/CustomPressGestureRecognizer.cs | 4 ++++ src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs | 3 +++ 18 files changed, 49 insertions(+) create mode 100644 src/Controls/src/Core/Compatibility/.editorconfig diff --git a/src/Controls/src/Core/Compatibility/.editorconfig b/src/Controls/src/Core/Compatibility/.editorconfig new file mode 100644 index 000000000000..eafffce1c3b1 --- /dev/null +++ b/src/Controls/src/Core/Compatibility/.editorconfig @@ -0,0 +1,7 @@ +# MemoryAnalyzers (MEM0001-0003) is being adopted incrementally (reviving dotnet/maui#18318). +# Modern handlers are enforced (error); the legacy Compatibility/ renderers are downgraded to +# suggestion for now and tracked for follow-up burn-down. +[*.cs] +dotnet_diagnostic.MEM0001.severity = suggestion +dotnet_diagnostic.MEM0002.severity = suggestion +dotnet_diagnostic.MEM0003.severity = suggestion diff --git a/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs index d58bdec65f5e..d38f9fbe51cf 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/CarouselViewController.cs @@ -134,6 +134,7 @@ public override void ViewDidLoad() base.ViewDidLoad(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] void OnDisplayInfoChanged(object sender, DisplayInfoChangedEventArgs e) { _isRotating = true; diff --git a/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs index 71d296a003b9..b9728ac2d8e1 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/DefaultCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using ObjCRuntime; @@ -9,8 +10,10 @@ namespace Microsoft.Maui.Controls.Handlers.Items { public abstract class DefaultCell : ItemsViewCell { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public UILabel Label { get; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] protected NSLayoutConstraint Constraint { get; set; } [Export("initWithFrame:")] diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs index 0a4978419899..0f7126622b33 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs @@ -17,6 +17,7 @@ public abstract class ItemsViewController : UICollectionViewControll public const int EmptyTag = 333; readonly WeakReference _itemsView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public IItemsViewSource ItemsSource { get; protected set; } public TItemsView ItemsView => _itemsView.GetTargetOrDefault(); @@ -193,6 +194,7 @@ public override void LoadView() CollectionView = collectionView; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] private void MovedToWindow(object sender, EventArgs e) { if (CollectionView?.Window != null) diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs index 3c76211b0a17..3f98028c282c 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewDelegator.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Foundation; @@ -15,6 +16,7 @@ public class ItemsViewDelegator : UICollectionViewD { readonly WeakReference _viewController; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public ItemsViewLayout ItemsViewLayout { get; } public TViewController ViewController => _viewController.TryGetTarget(out var vc) ? vc : null; diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs index 567343fa7e0b..fdd38c14e6c7 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewLayout.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Extensions.Logging; @@ -90,6 +91,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] void LayoutOnPropertyChanged(object sender, PropertyChangedEventArgs propertyChanged) { HandlePropertyChanged(propertyChanged); diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs index 230f48c98a27..bab91ec92c68 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ReorderableItemsViewController.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -12,6 +13,7 @@ public class ReorderableItemsViewController : GroupableItemsViewCont where TItemsView : ReorderableItemsView { bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] UILongPressGestureRecognizer _longPressGestureRecognizer; nint _lastMoveSourceSection = -1; nint _lastMoveDestinationSection = -1; diff --git a/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs index d0bfb95fe55c..e2869f8c3674 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/StructuredItemsViewController.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Graphics; using ObjCRuntime; @@ -15,9 +16,11 @@ public class StructuredItemsViewController : ItemsViewController : SelectableItemsViewCont // TemplatedCell2 _measurementCellTemplated; // DefaultCell2 _measurementCellDefault; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Transient: cleared (set to null) immediately after it is invoked, so it is not retained.")] Action _scrollAnimationEndedCallback; public GroupableItemsViewController2(TItemsView groupableItemsView, UICollectionViewLayout layout) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs index b50240693061..959829195d28 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewController2.cs @@ -20,6 +20,7 @@ public abstract class ItemsViewController2 : UICollectionViewControl public const int EmptyTag = 333; readonly WeakReference _itemsView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public Items.IItemsViewSource ItemsSource { get; protected set; } public TItemsView ItemsView => _itemsView.GetTargetOrDefault(); @@ -234,6 +235,7 @@ void InvalidateLayoutIfItemsMeasureChanged() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] private void MovedToWindow(object sender, EventArgs e) { if (CollectionView?.Window != null) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs index b820d1b23f79..1e857c1ff5ee 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ItemsViewDelegator2.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Foundation; @@ -16,6 +17,7 @@ public class ItemsViewDelegator2 : UICollectionView { readonly WeakReference _viewController; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] public UICollectionViewLayout ItemsViewLayout { get; } public TViewController ViewController => _viewController.TryGetTarget(out var vc) ? vc : null; diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs index 86d97cc7a6a4..e462e3216491 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/ReorderableItemsViewController2.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -12,6 +13,7 @@ public class ReorderableItemsViewController2 : GroupableItemsViewCon where TItemsView : ReorderableItemsView { bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] UILongPressGestureRecognizer _longPressGestureRecognizer; nint _lastMoveSourceSection = -1; nint _lastMoveDestinationSection = -1; diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index b8dad3ef4324..79cbbed63671 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Maui.Controls.Internals; @@ -68,8 +69,10 @@ public TemplatedCell2(CGRect frame) : base(frame) public UICollectionViewScrollDirection ScrollDirection { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] internal IPlatformViewHandler PlatformHandler { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")] internal UIView PlatformView { get; set; } CollectionViewHandler2 CollectionViewHandler diff --git a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs index 4c45a6b1581a..bb74367ff78a 100644 --- a/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs +++ b/src/Controls/src/Core/Platform/GestureManager/GesturePlatformManager.iOS.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using System.Reflection; using System.Runtime.Versioning; @@ -957,6 +958,7 @@ ButtonsMask AddFakeRightClickForMacCatalyst(IGestureRecognizer recognizer) internal class FakeRightClickContextMenuInteraction : UIContextMenuInteraction { // Store a reference to the platform delegate so that it is not garbage collected + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The strong reference is required to keep the UIContextMenuInteractionDelegate alive for the interaction lifetime.")] FakeRightClickDelegate? _dontCollectMePlease; public FakeRightClickContextMenuInteraction(TapGestureRecognizer tapGestureRecognizer, GesturePlatformManager gestureManager) @@ -995,6 +997,7 @@ public FakeRightClickDelegate(TapGestureRecognizer tapGestureRecognizer, Gesture internal class FakeRightClickPointerInteraction : UIContextMenuInteraction { // Store a reference to the platform delegate so that it is not garbage collected + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The strong reference is required to keep the UIContextMenuInteractionDelegate alive for the interaction lifetime.")] FakeRightClickPointerDelegate? _dontCollectMePlease; bool _disposed; diff --git a/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs b/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs index fd2124b7b1cf..4d8135a97894 100644 --- a/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs +++ b/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Foundation; using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific; using Microsoft.Maui.Graphics; @@ -10,6 +11,7 @@ namespace Microsoft.Maui.Controls.Platform { internal class ControlsModalWrapper : ModalWrapper, IUIAdaptivePresentationControllerDelegate { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The modal handler is owned by this wrapper while presented and is released in Dispose.")] IPlatformViewHandler? _modal; bool _isDisposed; Page Page => ((Page?)_modal?.VirtualView) ?? throw new InvalidOperationException("Page cannot be null here"); @@ -206,6 +208,7 @@ public override UIViewController ChildViewControllerForStatusBarStyle() return ChildViewControllers.Last(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The modal page PropertyChanged subscription is removed in Dispose before the modal handler is released.")] void OnModalPagePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == Page.BackgroundColorProperty.PropertyName) diff --git a/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs b/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs index 5758a89e0e9a..37ea0c86459b 100644 --- a/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs +++ b/src/Controls/src/Core/Platform/iOS/CustomPressGestureRecognizer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -10,7 +11,9 @@ namespace Microsoft.Maui.Controls.Platform.iOS; internal class CustomPressGestureRecognizer : UIGestureRecognizer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The recognizer owns the native target for its lifetime; retaining it keeps callback targets alive until the recognizer is released.")] NSObject _target; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The current UIEvent is replaced by touch callbacks and retained only for the gesture recognizer lifetime.")] UIEvent _currentEvent; ButtonsMask _detectedButton = ButtonsMask.Primary; @@ -36,6 +39,7 @@ public CustomPressGestureRecognizer(Action action) [Register("__UIGestureRecognizer")] class Callback : Token { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The callback token owns this delegate so the recognizer action remains invokable for the recognizer lifetime.")] Action action; internal Callback(Action action) diff --git a/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs b/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs index fb864d2ac188..437ef5f8148b 100644 --- a/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs +++ b/src/Controls/src/Core/Platform/iOS/DragAndDropDelegate.cs @@ -1,6 +1,7 @@ #nullable disable #if __MOBILE__ using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.Versioning; using CoreGraphics; using Foundation; @@ -13,6 +14,7 @@ namespace Microsoft.Maui.Controls.Platform [SupportedOSPlatform("ios11.0")] class DragAndDropDelegate : NSObject, IUIDragInteractionDelegate, IUIDropInteractionDelegate { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The drag/drop delegate releases the handler reference in Disconnect when the platform interactions are disconnected.")] IPlatformViewHandler _viewHandler; PlatformDragStartingEventArgs _platformDragStartingEventArgs; @@ -361,6 +363,7 @@ void HandleDrop(View element, DataPackage datapackage, IUIDropSession session, P class CustomLocalStateData : NSObject { public View View { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The local drag state is session-scoped and released by UIKit when the drag session ends.")] public IViewHandler Handler { get; set; } public DataPackage DataPackage { get; set; } } From 3e12ee35ee01d9358112ffd72cb128b6254f7e17 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:54:46 +0200 Subject: [PATCH 03/26] Remove .editorconfig severity downgrade; enforce MemoryAnalyzers at error on Compatibility Per review feedback (@jonathanpeppers): downgrading MEM0001/2/3 to suggestion just silences the analyzer on legacy renderers instead of enforcing it. The findings will be handled individually (real fix where clean, else specific justified suppression). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Controls/src/Core/Compatibility/.editorconfig | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/Controls/src/Core/Compatibility/.editorconfig diff --git a/src/Controls/src/Core/Compatibility/.editorconfig b/src/Controls/src/Core/Compatibility/.editorconfig deleted file mode 100644 index eafffce1c3b1..000000000000 --- a/src/Controls/src/Core/Compatibility/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -# MemoryAnalyzers (MEM0001-0003) is being adopted incrementally (reviving dotnet/maui#18318). -# Modern handlers are enforced (error); the legacy Compatibility/ renderers are downgraded to -# suggestion for now and tracked for follow-up burn-down. -[*.cs] -dotnet_diagnostic.MEM0001.severity = suggestion -dotnet_diagnostic.MEM0002.severity = suggestion -dotnet_diagnostic.MEM0003.severity = suggestion From 036bbbfbc65113b5f22fbb2160d133c29477d5d9 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:11:20 +0200 Subject: [PATCH 04/26] Resolve MemoryAnalyzers findings: Navigation/FlyoutPage/Tabbed + iOS renderers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs | 14 ++++++++++++ .../NavigationPage/iOS/NavigationRenderer.cs | 19 ++++++++++++++++ .../Handlers/TabbedPage/iOS/TabbedRenderer.cs | 14 ++++++++++++ .../Handlers/VisualElementRenderer.cs | 8 +++++++ .../Handlers/iOS/FrameRenderer.cs | 3 +++ .../Handlers/iOS/ViewRenderer.cs | 4 +++- .../Handlers/iOS/VisualElementRenderer.cs | 2 ++ .../iOS/Extensions/ToolbarItemExtensions.cs | 22 +++++++++++++++---- .../GlobalCloseContextGestureRecognizer.cs | 2 ++ .../iOS/NativeViewPropertyListener.cs | 2 ++ 10 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs index 9ec7e8447a56..1040e19bea78 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Microsoft.Maui.Controls.Internals; @@ -15,16 +16,21 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility { public class PhoneFlyoutPageRenderer : UIViewController, IPlatformViewHandler { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The click-off view is owned by the renderer and disposed in Dispose.")] UIView _clickOffView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The detail child controller is owned by the renderer while its containers are packed and emptied in Dispose.")] UIViewController _detailController; WeakReference _element; bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout child controller is owned by the renderer while its containers are packed and emptied in Dispose.")] UIViewController _flyoutController; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The pan gesture recognizer is removed from the view and disposed in Dispose.")] UIPanGestureRecognizer _panGesture; bool _presented; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The tap gesture recognizer is removed from the click-off view and disposed in Dispose.")] UIGestureRecognizer _tapGesture; bool _applyShadow; @@ -34,6 +40,7 @@ public class PhoneFlyoutPageRenderer : UIViewController, IPlatformViewHandler Page Page => Element as Page; IFlyoutPageController FlyoutPageController => FlyoutPage; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext _mauiContext; IMauiContext MauiContext => _mauiContext; @@ -58,6 +65,7 @@ public bool FlyoutOverlapsDetailsInPopoverMode bool IsRTL => (Element as IVisualElementController)?.EffectiveFlowDirection.IsRightToLeft() == true; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper); public static CommandMapper CommandMapper = new CommandMapper(ViewHandler.ViewCommandMapper); ViewHandlerDelegator _viewHandlerWrapper; @@ -77,6 +85,7 @@ bool Presented public VisualElement Element => _viewHandlerWrapper.Element ?? _element?.GetTargetOrDefault(); + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler ElementChanged; public Size GetDesiredSize(double widthConstraint, double heightConstraint) @@ -89,6 +98,7 @@ public UIView NativeView get { return View; } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Element SizeChanged subscription is removed in Dispose.")] public void SetElement(VisualElement element) { @@ -163,6 +173,7 @@ void SetInitialPresented() UpdateLeftBarButton(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The FlyoutPage PropertyChanged subscription is removed in Dispose.")] public override void ViewDidLoad() { base.ViewDidLoad(); @@ -242,6 +253,8 @@ protected override void Dispose(bool disposing) { Element.SizeChanged -= PageOnSizeChanged; Element.PropertyChanged -= HandlePropertyChanged; + if (Element is FlyoutPage flyoutPage) + flyoutPage.Flyout.PropertyChanged -= HandleFlyoutPropertyChanged; if (_tapGesture != null) { @@ -555,6 +568,7 @@ void UpdateBackground() }); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Flyout page PropertyChanged subscription is removed when containers update and in Dispose.")] void UpdateFlyoutPageContainers() { ((FlyoutPage)Element).Flyout.PropertyChanged -= HandleFlyoutPropertyChanged; diff --git a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs index f3b2044b33f1..27adce811c47 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using CoreGraphics; @@ -33,7 +34,9 @@ public class NavigationRenderer : UINavigationController, INavigationViewHandler bool _appeared; bool _ignorePopCall; FlyoutPage _parentFlyoutPage; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The controllers-to-remove array is transient navigation state cleared after UIKit removal completes.")] UIViewController[] _removeControllers; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The secondary toolbar is owned by the renderer and disposed in Dispose.")] UIToolbar _secondaryToolbar; bool _hasNavigationBar; UIImage _defaultNavBarShadowImage; @@ -41,8 +44,10 @@ public class NavigationRenderer : UINavigationController, INavigationViewHandler Brush _currentBarBackgroundBrush; Color _currentBarBackgroundColor; bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext _mauiContext; IMauiContext MauiContext => _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper) { [PlatformConfiguration.iOSSpecific.NavigationPage.PrefersLargeTitlesProperty.PropertyName] = NavigationPage.MapPrefersLargeTitles, @@ -77,6 +82,7 @@ Page Current public VisualElement Element { get => _viewHandlerWrapper.Element ?? _element?.GetTargetOrDefault(); } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler ElementChanged; #pragma warning disable CS0618 // Type or member is obsolete @@ -203,6 +209,7 @@ public override void ViewWillLayoutSubviews() (Element as IView).Arrange(View.Bounds.ToRectangle()); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "NavigationPage request and PropertyChanged subscriptions added in ViewDidLoad are removed in Dispose.")] public override void ViewDidLoad() { base.ViewDidLoad(); @@ -251,6 +258,7 @@ public override void ViewDidLoad() class GestureDelegate : UIGestureRecognizerDelegate { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The should-pop callback is owned by the gesture delegate while InteractivePopGestureRecognizer.Delegate is set and released in NavigationRenderer.Dispose.")] readonly Func _shouldPop; public GestureDelegate(Func shouldPop) @@ -810,6 +818,7 @@ void OnBarBackgroundChanged(object sender, EventArgs e) RefreshBarBackground(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Gradient brush InvalidateGradientBrushRequested is removed before replacing the brush and in Dispose.")] void UpdateBarBackground() { if (_currentBarBackgroundBrush is GradientBrush oldGradientBrush) @@ -1387,6 +1396,7 @@ public ParentingViewController(NavigationRenderer navigation) _navigation = new WeakReference(navigation); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Child PropertyChanged subscriptions are removed when Child changes and in Disconnect.")] public Page Child { get => _child?.GetTargetOrDefault(); @@ -1417,6 +1427,7 @@ public Page Child } } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Appearing is a legacy ParentingViewController event consumed by the navigation renderer lifecycle.")] public event EventHandler Appearing; [System.Runtime.Versioning.UnsupportedOSPlatform("ios8.0")] @@ -1428,6 +1439,7 @@ public override void DidRotate(UIInterfaceOrientation fromInterfaceOrientation) View.SetNeedsLayout(); } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Disappearing is a legacy ParentingViewController event consumed by the navigation renderer lifecycle.")] public event EventHandler Disappearing; public override void ViewDidAppear(bool animated) @@ -1503,6 +1515,7 @@ public override void ViewDidLayoutSubviews() UpdateFrames(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Toolbar tracker CollectionChanged is removed in Disconnect.")] public override void ViewDidLoad() { base.ViewDidLoad(); @@ -1997,6 +2010,7 @@ void CleanToolbarItems() _trackedToolbarItems.Clear(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ToolbarItem PropertyChanged subscriptions are removed by CleanToolbarItems before replacement and in Disconnect.")] void UpdateToolbarItems() { // Unsubscribe from previous toolbar item property changes @@ -2251,6 +2265,7 @@ protected internal MauiControlsNavigationBar(NativeHandle handle) : base(handle) } public RectangleF BackButtonFrameSize { get; private set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The navigation bar label is a cached native subview reference updated or cleared during LayoutSubviews.")] public UILabel NavBarLabel { get; private set; } public override void LayoutSubviews() @@ -2288,8 +2303,11 @@ public override void LayoutSubviews() class Container : UIView { View _view; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The navigation bar reference is used only for title layout while the container is attached and released with the container.")] MauiControlsNavigationBar _bar; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The title view child handler is disconnected and cleared in Dispose.")] IPlatformViewHandler _child; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The title icon view is owned by the container and disposed in Dispose.")] UIImageView _icon; bool _disposed; nfloat? _navigationBarHeight; @@ -2316,6 +2334,7 @@ internal Container(View view, UINavigationBar bar, CGRect navigationBarFrame) : InitializeContainer(view, bar, navigationBarFrame.Height); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The title view ParentSet subscription is removed when it fires and in Dispose.")] void InitializeContainer(View view, UINavigationBar bar, nfloat? navigationBarHeight) { // iOS 26+ and MacCatalyst 26+ require autoresizing masks instead of constraints diff --git a/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs index 3ab57244b64b..2e600207ae18 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.Maui.Controls.Internals; @@ -25,13 +26,16 @@ public class TabbedRenderer : UITabBarController, IPlatformViewHandler UIColor _defaultBarColor; bool _defaultBarColorSet; bool? _defaultBarTranslucent; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The tab bar appearance is owned by the renderer and disposed in Dispose.")] UITabBarAppearance _tabBarAppearance; WeakReference _element; Brush _currentBarBackground; IMauiContext MauiContext => _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(TabbedViewHandler.ViewMapper); public static CommandMapper CommandMapper = new CommandMapper(TabbedViewHandler.ViewCommandMapper); @@ -67,6 +71,7 @@ protected TabbedPage Tabbed public VisualElement Element => _viewHandlerWrapper?.Element ?? _element?.GetTargetOrDefault(); + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler ElementChanged; public Size GetDesiredSize(double widthConstraint, double heightConstraint) => @@ -77,6 +82,7 @@ public UIView NativeView get { return View; } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "TabbedPage PropertyChanged and PagesChanged subscriptions are removed in Dispose.")] public void SetElement(VisualElement element) { _viewHandlerWrapper.SetVirtualView(element, OnElementChanged, false); @@ -152,8 +158,14 @@ protected override void Dispose(bool disposing) { tabbed.PropertyChanged -= OnPropertyChanged; tabbed.PagesChanged -= OnPagesChanged; + + foreach (var page in tabbed.Children) + page.PropertyChanged -= OnPagePropertyChanged; } + if (_currentBarBackground is GradientBrush gradientBrush) + gradientBrush.InvalidateGradientBrushRequested -= OnBarBackgroundChanged; + FinishedCustomizingViewControllers -= HandleFinishedCustomizingViewControllers; } @@ -367,6 +379,7 @@ void SetControllers() UpdateTabBarVisibility(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Page PropertyChanged subscriptions are removed in TeardownPage and Dispose.")] void SetupPage(Page page, int index) { var renderer = (IPlatformViewHandler)page.ToHandler(_mauiContext); @@ -410,6 +423,7 @@ void UpdateBarBackgroundColor() TabBar.BarTintColor = isDefaultColor ? _defaultBarColor : barBackgroundColor.ToPlatform(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Gradient brush InvalidateGradientBrushRequested is removed before replacing the brush and in Dispose.")] void UpdateBarBackground() { if (Tabbed is not TabbedPage tabbed || TabBar == null) diff --git a/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs index a0deb8ed8e64..1d07b352f755 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/VisualElementRenderer.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; @@ -28,6 +29,7 @@ public abstract partial class VisualElementRenderer : IPlatformViewHan where TElement : Element, IView #endif { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper VisualElementRendererMapper = new PropertyMapper(ViewHandler.ViewMapper) { [nameof(IView.AutomationId)] = MapAutomationId, @@ -48,13 +50,17 @@ public abstract partial class VisualElementRenderer : IPlatformViewHan #if IOS || MACCATALYST WeakReference? _virtualView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The temporary element is cleared immediately after SetVirtualView completes on iOS and Mac Catalyst.")] TElement? _tempElement; #else TElement? _virtualView; #endif + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The Maui context is required for the compatibility renderer lifetime and is not exposed outside the handler.")] IMauiContext? _mauiContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The active mapper is handler metadata reset during SetVirtualView and used for renderer property updates.")] internal IPropertyMapper _mapper; internal readonly CommandMapper? _commandMapper; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The default mapper is handler metadata retained for the renderer lifetime to reset property mapping.")] internal readonly IPropertyMapper _defaultMapper; protected IMauiContext MauiContext => _mauiContext ?? throw new InvalidOperationException("MauiContext not set"); #if IOS || MACCATALYST @@ -95,7 +101,9 @@ protected VisualElementRenderer(IPropertyMapper mapper, CommandMapper? commandMa _commandMapper = commandMapper; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler>? ElementChanged; + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "ElementPropertyChanged is a legacy public compatibility renderer event kept for API compatibility.")] public event EventHandler? ElementPropertyChanged; public void SetElement(IView view) diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs index 952fe4f00e5e..76179904f2d4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/FrameRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Controls.Platform; using UIKit; @@ -10,6 +11,7 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility [Obsolete("Frame is obsolete as of .NET 9. Please use Border instead.")] public class FrameRenderer : VisualElementRenderer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The mapper is static shared handler metadata and is not retained by renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(VisualElementRendererMapper) { @@ -26,6 +28,7 @@ public static IPropertyMapper Mapper public static CommandMapper CommandMapper = new CommandMapper(VisualElementRendererCommandMapper); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The frame content view is owned by the renderer and disposed in Dispose.")] FrameView _actualView; CGSize _previousSize; bool _isDisposed; diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs index 44ce4c6c3c26..5c3d62327ad6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/ViewRenderer.cs @@ -1,4 +1,5 @@ -using CoreGraphics; +using System.Diagnostics.CodeAnalysis; +using CoreGraphics; using UIKit; using PlatformView = UIKit.UIView; @@ -15,6 +16,7 @@ public abstract partial class ViewRenderer : VisualElem where TElement : View, IView where TPlatformView : PlatformView { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The native view is cleared in DisconnectHandlerCore before the renderer disconnects.")] TPlatformView? _nativeView; public TPlatformView? Control diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs index f1188b1bc7ef..aad33b3d4faf 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/VisualElementRenderer.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls.Platform; using UIKit; @@ -68,6 +69,7 @@ partial void ElementPropertyChangedPartial(object sender, PropertyChangedEventAr } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "VisualElement SizeChanged and BatchCommitted subscriptions are removed when the element changes.")] partial void ElementChangedPartial(ElementChangedEventArgs e) { if (e.OldElement is VisualElement oldVe) diff --git a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs index 2ad07699ed1c..9df445f0d08c 100644 --- a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs +++ b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Maui.Graphics; @@ -125,11 +126,15 @@ void OnClicked(object sender, EventArgs e) protected override void Dispose(bool disposing) { - if (disposing && _item.TryGetTarget(out var item)) - item.PropertyChanged -= OnPropertyChanged; + if (disposing) + { + if (_item.TryGetTarget(out var item)) + item.PropertyChanged -= OnPropertyChanged; + } base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ToolbarItem PropertyChanged subscription is removed in Dispose.")] void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { if (!_item.TryGetTarget(out var item)) @@ -296,6 +301,7 @@ public SecondaryToolbarItem(ToolbarItem item) : base(new SecondaryToolbarItemCon #pragma warning restore CS0618 // Type or member is obsolete } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The TouchUpInside subscription is removed in Dispose.")] void OnClicked(object sender, EventArgs e) { if (_item.TryGetTarget(out var item)) @@ -306,11 +312,17 @@ void OnClicked(object sender, EventArgs e) protected override void Dispose(bool disposing) { - if (disposing && _item.TryGetTarget(out var item)) - item.PropertyChanged -= OnPropertyChanged; + if (disposing) + { + ((SecondaryToolbarItemContent)CustomView).TouchUpInside -= OnClicked; + + if (_item.TryGetTarget(out var item)) + item.PropertyChanged -= OnPropertyChanged; + } base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ToolbarItem PropertyChanged subscription is removed in Dispose.")] void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { if (!_item.TryGetTarget(out var item)) @@ -357,7 +369,9 @@ void UpdateText(ToolbarItem item) sealed class SecondaryToolbarItemContent : UIControl { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The image view is a child view owned by SecondaryToolbarItemContent for its UIControl lifetime.")] readonly UIImageView _imageView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The label is a child view owned by SecondaryToolbarItemContent for its UIControl lifetime.")] readonly UILabel _label; public SecondaryToolbarItemContent() diff --git a/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs b/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs index 1f5f7a4cbb99..07f49724f2bc 100644 --- a/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs +++ b/src/Controls/src/Core/Compatibility/iOS/GlobalCloseContextGestureRecognizer.cs @@ -1,4 +1,5 @@ #nullable disable +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -9,6 +10,7 @@ namespace Microsoft.Maui.Controls.Compatibility.Platform.iOS { internal class GlobalCloseContextGestureRecognizer : UIGestureRecognizer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The scroll view is needed by ShouldReceiveTouch and is cleared in Dispose.")] UIScrollView _scrollView; public GlobalCloseContextGestureRecognizer(UIScrollView scrollView, NSAction activated) : base(activated) diff --git a/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs b/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs index 1bf83b6cb54d..1d4f42f1e160 100644 --- a/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs +++ b/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Foundation; #if __MOBILE__ @@ -19,6 +20,7 @@ public NativeViewPropertyListener(string targetProperty) TargetProperty = targetProperty; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "PropertyChanged must be public to implement INotifyPropertyChanged for the native property listener.")] public event PropertyChangedEventHandler PropertyChanged; public override void ObserveValue(NSString keyPath, NSObject ofObject, NSDictionary change, IntPtr context) From 4b04c0e1e7a4166e69cfea4029465f52e929fe15 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:14:10 +0200 Subject: [PATCH 05/26] Resolve MemoryAnalyzers findings: ListView/TableView + Shell support renderers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ListView/iOS/CellTableViewCell.cs | 5 +++- .../ListView/iOS/ContextActionCell.cs | 9 +++++++ .../ListView/iOS/ContextScrollViewDelegate.cs | 20 +++++++++++++++ .../ListView/iOS/EntryCellRenderer.cs | 17 +++++++++++++ .../Handlers/ListView/iOS/ListViewRenderer.cs | 15 +++++++++++ .../Handlers/ListView/iOS/ViewCellRenderer.cs | 2 ++ .../Handlers/Shell/iOS/ShellRenderer.cs | 25 +++++++++++++++++++ .../Shell/iOS/ShellSearchResultsRenderer.cs | 6 +++++ .../Shell/iOS/ShellTableViewController.cs | 5 ++++ .../Shell/iOS/ShellTableViewSource.cs | 7 +++++- .../Handlers/Shell/iOS/UIContainerCell.cs | 10 ++++++++ .../Handlers/Shell/iOS/UIContainerView.cs | 4 +++ .../TableView/iOS/TableViewModelRenderer.cs | 1 + .../TableView/iOS/TableViewRenderer.cs | 2 ++ 14 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs index bac7e104ccc9..eb4564b2fc0b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.ComponentModel; using Microsoft.Maui.Controls.Compatibility; using ObjCRuntime; @@ -13,6 +14,7 @@ public class CellTableViewCell : UITableViewCell, INativeElementView WeakReference _cell; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Callback is cleared in Dispose(bool) before the cell is released.")] public Action PropertyChanged; bool _disposed; @@ -46,7 +48,8 @@ public Cell Cell } else { - _cell = null; + PropertyChanged = null; + _cell = null; } } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs index b93b530fe618..8bc030533c3c 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; @@ -27,8 +28,11 @@ internal sealed class ContextActionsCell : UITableViewCell, INativeElementView #pragma warning disable CS0618 // Type or member is obsolete Cell _cell; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Button is owned for the context-actions cell lifetime and disposed in Dispose(bool).")] UIButton _moreButton; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Scroll view is owned for the context-actions cell lifetime and disposed in Dispose(bool).")] UIScrollView _scroller; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view reference is cleared in Dispose(bool) when the cell is released.")] UITableView _tableView; bool _isDiposed; @@ -55,6 +59,7 @@ public ContextActionsCell(string templateId) : base(UITableViewCellStyle.Default { } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Content cell is removed during updates and cleared in Dispose(bool).")] public UITableViewCell ContentCell { get; private set; } public bool IsOpen => ScrollDelegate.IsOpen; @@ -131,6 +136,7 @@ public override void RemoveFromSuperview() } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Cell and context-action subscriptions are removed in Update and Dispose(bool).")] public void Update(UITableView tableView, Cell cell, UITableViewCell nativeCell) #pragma warning restore CS0618 // Type or member is obsolete { @@ -280,6 +286,7 @@ protected override void Dispose(bool disposing) } _tableView = null; + ContentCell = null; _moreButton?.Dispose(); _moreButton = null; @@ -520,6 +527,7 @@ void ReloadRowCore() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Button handlers are attached to buttons owned by this cell and the buttons are disposed in Update and Dispose(bool).")] UIView SetupButtons(nfloat width, nfloat height) { MenuItem destructive = null; @@ -641,6 +649,7 @@ internal static void SetupSelection(UITableView table) private sealed class SelectGestureRecognizer : UITapGestureRecognizer { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Gesture recognizer stores only the last touched index path while attached to the table view.")] NSIndexPath _lastPath; public SelectGestureRecognizer() : base(Tapped) diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs index 1b03f18de43f..89ad588e834f 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using ObjCRuntime; using UIKit; @@ -42,14 +43,19 @@ public override void LayoutSubviews() internal sealed class ContextScrollViewDelegate : UIScrollViewDelegate { readonly nfloat _finalButtonSize; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Background view reference is cleared in Dispose(bool) or after RestoreHighlight reinserts it.")] UIView _backgroundView; List _buttons; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Tap recognizer is removed and disposed by ClearCloserRecognizer or Dispose(bool).")] UITapGestureRecognizer _closer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Container view reference is cleared in Dispose(bool) when the delegate is released.")] UIView _container; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Global close recognizer is removed from the table and disposed by ClearCloserRecognizer or Dispose(bool).")] Controls.Compatibility.Platform.iOS.GlobalCloseContextGestureRecognizer _globalCloser; bool _isDisposed; static WeakReference s_scrollViewBeingScrolled; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view reference is cleared in Dispose(bool) and when the global close recognizer is removed.")] UITableView _table; public ContextScrollViewDelegate(UIView container, List buttons, bool isOpen) @@ -70,6 +76,7 @@ public ContextScrollViewDelegate(UIView container, List buttons, bool public nfloat ButtonsWidth { get; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Callback is cleared in Dispose(bool) when the delegate is released.")] public Action ClosedCallback { get; set; } public bool IsOpen { get; private set; } @@ -216,6 +223,19 @@ protected override void Dispose(bool disposing) { ClosedCallback = null; + if (_closer != null) + { + _closer.Dispose(); + _closer = null; + } + + if (_globalCloser != null) + { + _table?.RemoveGestureRecognizer(_globalCloser); + _globalCloser.Dispose(); + _globalCloser = null; + } + s_scrollViewBeingScrolled = null; _table = null; _backgroundView = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs index 3e0f4dd8eef3..d6a02b035752 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/EntryCellRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.ComponentModel; using System.Runtime.Versioning; using Foundation; @@ -196,8 +197,10 @@ public EntryCellTableViewCell(string cellName) : base(UITableViewCellStyle.Value ContentView.AddSubview(TextField); } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Text field is owned as a UIKit subview for the entry-cell lifetime.")] public UITextField TextField { get; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) and renderer subscriptions are removed when cells are reused.")] public event EventHandler KeyboardDoneButtonPressed; public override void LayoutSubviews() @@ -219,8 +222,22 @@ public override void LayoutSubviews() TextField.VerticalAlignment = UIControlContentVerticalAlignment.Center; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) and renderer subscriptions are removed when cells are reused.")] public event EventHandler TextFieldTextChanged; + protected override void Dispose(bool disposing) + { + if (disposing) + { + TextField.EditingChanged -= TextFieldOnEditingChanged; + TextField.ShouldReturn = null; + KeyboardDoneButtonPressed = null; + TextFieldTextChanged = null; + } + + base.Dispose(disposing); + } + static bool OnShouldReturn(UITextField view) { var realCell = GetRealCell(view); diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs index 268fdb757c75..4103beb4646a 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; @@ -40,14 +41,19 @@ public class ListViewRenderer : ViewRenderer const int DefaultRowHeight = 44; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Background view is owned for the renderer lifetime and disposed in CleanUpResources.")] UIView _backgroundUIView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Data source is owned for the renderer lifetime and disposed in CleanUpResources.")] ListViewDataSource _dataSource; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Header handler is owned for the renderer lifetime and cleared in CleanUpResources.")] IPlatformViewHandler _headerRenderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Footer handler is owned for the renderer lifetime and cleared in CleanUpResources.")] IPlatformViewHandler _footerRenderer; RectangleF _previousFrame; ScrollToRequestedEventArgs _requestedScroll; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view controller is owned for the renderer lifetime and disposed in CleanUpResources.")] FormsUITableViewController _tableViewController; #pragma warning disable CS0618 // Type or member is obsolete ListView ListView => Element; @@ -201,6 +207,7 @@ void CleanUpResources() Control?.TableFooterView?.Dispose(); } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListView and templated-items subscriptions are removed for the old element and in CleanUpResources.")] protected override void OnElementChanged(ElementChangedEventArgs e) #pragma warning restore CS0618 // Type or member is obsolete { @@ -485,6 +492,7 @@ void OnScrollToRequested(object sender, ScrollToRequestedEventArgs e) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Footer MeasureInvalidated subscription is removed when the footer changes and in CleanUpResources.")] void UpdateFooter() { var footer = ListView.FooterElement; @@ -524,6 +532,7 @@ void UpdateFooter() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Header MeasureInvalidated subscription is removed when the header changes and in CleanUpResources.")] void UpdateHeader() { var header = ListView.HeaderElement; @@ -838,6 +847,7 @@ void UpdateHorizontalScrollBarVisibility() internal sealed class UnevenListViewDataSource : ListViewDataSource { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Prototype handler is disconnected and cleared in ClearPrototype from Dispose(bool).")] IPlatformViewHandler _prototype; bool _disposed; #pragma warning disable CS0618 // Type or member is obsolete @@ -1068,6 +1078,7 @@ public ListViewDataSource(ListViewDataSource source) } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListView ItemSelected subscription is removed in ListViewDataSource.Dispose(bool).")] public ListViewDataSource(ListView list, FormsUITableViewController uiTableViewController) #pragma warning restore CS0618 // Type or member is obsolete { @@ -1541,6 +1552,7 @@ void UpdateShortNameListener() WatchShortNameCollection(list.IsGroupingEnabled); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ShortNames CollectionChanged subscription is removed by WatchShortNameCollection(false) in Dispose(bool).")] void WatchShortNameCollection(bool watch) { if (!_list.TryGetTarget(out var list)) @@ -1630,6 +1642,7 @@ public HeaderWrapperView(string reuseIdentifier) : base((NSString)reuseIdentifie { } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Header/footer wrapper owns the table view cell as a UIKit subview for the wrapper lifetime.")] UITableViewCell _tableViewCell; #pragma warning disable CS0618 // Type or member is obsolete @@ -1658,6 +1671,7 @@ internal sealed class FormsUITableViewController : UITableViewController #pragma warning disable CS0618 // Type or member is obsolete readonly WeakReference _list; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Refresh control is owned by the table controller and disposed in Dispose(bool).")] UIRefreshControl _refresh; bool _refreshAdded; @@ -1667,6 +1681,7 @@ internal sealed class FormsUITableViewController : UITableViewController bool _isStartRefreshingPending; #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Refresh control ValueChanged subscription is removed in FormsUITableViewController.Dispose(bool).")] public FormsUITableViewController(ListView element, bool usingLargeTitles) : base(element.OnThisPlatform().GetGroupHeaderStyle() == GroupHeaderStyle.Plain ? UITableViewStyle.Plain diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs index ee32039a79e5..e4ffa73c0704 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ViewCellRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.ComponentModel; using Microsoft.Maui.Controls.Compatibility; using Microsoft.Maui.Controls.Internals; @@ -183,6 +184,7 @@ IPlatformViewHandler GetNewRenderer() } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ViewCell PropertyChanged and MeasureInvalidated subscriptions are removed when the cell changes and in Dispose(bool).")] void UpdateCell(ViewCell cell) #pragma warning restore CS0618 // Type or member is obsolete { diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index 4568795b1fef..6afaf025c996 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.ComponentModel; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -13,6 +14,7 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility { public class ShellRenderer : UIViewController, IShellContext, IPlatformViewHandler { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Static mapper is shared for the renderer type and does not capture renderer instances.")] public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ViewMapper); public static CommandMapper CommandMapper = new CommandMapper(ViewHandler.ViewCommandMapper); @@ -92,11 +94,15 @@ IShellTabBarAppearanceTracker IShellContext.CreateTabBarAppearanceTracker() #endregion IShellContext + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Current item renderer is owned by ShellRenderer and disposed when replaced or when ShellRenderer is disposed.")] IShellItemRenderer _currentShellItemRenderer; bool _disposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Flyout renderer is owned by ShellRenderer and disposed in Dispose(bool).")] IShellFlyoutRenderer _flyoutRenderer; Task _activeTransition = Task.CompletedTask; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Incoming item renderer is a transient transition reference cleared when ShellRenderer is disposed.")] IShellItemRenderer _incomingRenderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "MauiContext is provided by the handler and cleared when ShellRenderer is disposed.")] IMauiContext _mauiContext; IShellFlyoutRenderer FlyoutRenderer @@ -113,6 +119,7 @@ IShellFlyoutRenderer FlyoutRenderer set { _flyoutRenderer = value; } } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) when ShellRenderer is released.")] public event EventHandler ElementChanged; public VisualElement Element { get; private set; } @@ -208,9 +215,26 @@ protected override void Dispose(bool disposing) if (disposing && !_disposed) { _disposed = true; + + if (Element != null) + Element.PropertyChanged -= OnElementPropertyChanged; + + ElementChanged = null; + + if (!ReferenceEquals(_incomingRenderer, _currentShellItemRenderer)) + { + (_incomingRenderer as IDisconnectable)?.Disconnect(); + _incomingRenderer?.Dispose(); + } + + (_currentShellItemRenderer as IDisconnectable)?.Disconnect(); + _currentShellItemRenderer?.Dispose(); FlyoutRenderer?.Dispose(); } + _incomingRenderer = null; + _currentShellItemRenderer = null; + _mauiContext = null; FlyoutRenderer = null; } @@ -280,6 +304,7 @@ void UpdateFlowDirection(bool readdViews = false) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Shell PropertyChanged subscription is removed in Dispose(bool).")] protected virtual void OnElementSet(Shell element) { if (element == null) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs index befd05e5c83f..0370c83aaef4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSearchResultsRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Specialized; using Foundation; using Microsoft.Extensions.Logging; @@ -27,6 +28,7 @@ SearchHandler IShellSearchResultsRenderer.SearchHandler #endregion IShellSearchResultsRenderer + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Shell context is required for the search results renderer lifetime.")] readonly IShellContext _context; DataTemplate _defaultTemplate; @@ -72,6 +74,7 @@ DataTemplate DefaultTemplate } } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is cleared in Dispose(bool) when the search results renderer is released.")] public event EventHandler ItemSelected; public ShellSearchResultsRenderer(IShellContext context) @@ -100,6 +103,7 @@ protected override void Dispose(bool disposing) SearchController.ListProxyChanged -= OnListProxyChanged; } + ItemSelected = null; SearchHandler = null; } @@ -164,6 +168,7 @@ NSIndexPath[] GetPaths(int section, int index, int count) return paths; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListProxy CollectionChanged subscription is removed when the proxy changes and in Dispose(bool).")] void OnListProxyChanged(object sender, ListProxyChangedEventArgs e) { if (e.OldList != null) @@ -266,6 +271,7 @@ void OnProxyCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ListProxyChanged subscription is removed in Dispose(bool).")] void OnSearchHandlerSet() { SearchController.ListProxyChanged += OnListProxyChanged; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs index fee6f50e0e9c..68282c8f53a6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Specialized; using System.ComponentModel; using CoreAnimation; @@ -11,9 +12,12 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class ShellTableViewController : UITableViewController { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Shell context is required for the table controller lifetime and event subscriptions are removed in Dispose(bool).")] readonly IShellContext _context; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table source is owned by the controller and detached from events in Dispose(bool).")] readonly ShellTableViewSource _source; bool _isDisposed; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Selection callback is cleared in Dispose(bool) when the controller is released.")] Action _onElementSelected; IShellController ShellController => _context.Shell; @@ -23,6 +27,7 @@ public ShellTableViewController(IShellContext context, UIContainerView headerVie HeaderView = headerView; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "FlyoutItemsChanged and ScrolledEvent subscriptions are removed in Dispose(bool).")] public ShellTableViewController(IShellContext context, Action onElementSelected) { ShellFlyoutContentManager = ShellFlyoutContentManager ?? new ShellFlyoutLayoutManager(context); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs index e05ac3c9d51e..4ef611a98a19 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using Foundation; using Microsoft.Maui.Controls.Internals; @@ -11,7 +12,9 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class ShellTableViewSource : UITableViewSource { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Shell context is required while the table source is owned by ShellTableViewController.")] readonly IShellContext _context; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Selection callback is owned by ShellTableViewController for the table source lifetime.")] readonly Action _onElementSelected; List> _groups; Dictionary _cells; @@ -23,6 +26,7 @@ public ShellTableViewSource(IShellContext context, Action onElementSele _onElementSelected = onElementSelected; } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is owned by ShellTableViewController and unsubscribed in ShellTableViewController.Dispose(bool).")] public event EventHandler ScrolledEvent; public List> Groups @@ -198,7 +202,7 @@ public override UITableViewCell GetCell(UITableView tableView, NSIndexPath index return cell; } - void OnViewMeasureInvalidated(UIContainerCell cell) + static void OnViewMeasureInvalidated(UIContainerCell cell) { cell.ReloadRow(); } @@ -247,6 +251,7 @@ public override void WillDisplay(UITableView tableView, UITableViewCell cell, NS class SeparatorView : UIView { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Separator line is owned as a UIKit subview for the separator view lifetime.")] UIView _line; public SeparatorView() diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs index b93e55b34e83..7f1823c9419b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using Foundation; using ObjCRuntime; using UIKit; @@ -8,13 +9,19 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class UIContainerCell : UITableViewCell { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Renderer is owned by the container cell and disconnected in Disconnect.")] IPlatformViewHandler _renderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Binding context is unsubscribed from PropertyChanged and cleared in Disconnect.")] object _bindingContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Measure callback is cleared in Disconnect before the cell is released.")] internal Action ViewMeasureInvalidated { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Index path is cleared in Disconnect when the cell is released.")] internal NSIndexPath IndexPath { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Table view reference is cleared in Disconnect when the cell is released.")] internal UITableView TableView { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "View MeasureInvalidated subscription is removed in Disconnect.")] internal UIContainerCell(string cellId, View view, Shell shell, object context) : base(UITableViewCellStyle.Default, cellId) { View = view; @@ -89,12 +96,15 @@ internal void Disconnect(Shell shell = null, bool keepRenderer = false) View.Handler = null; + _renderer = null; + IndexPath = null; View = null; TableView = null; } public View View { get; private set; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "BaseShellItem PropertyChanged subscription is removed when BindingContext changes and in Disconnect.")] public object BindingContext { get => _bindingContext; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs index 7484b56266a8..6cf86bf68421 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Graphics; using ObjCRuntime; @@ -10,11 +11,14 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility public class UIContainerView : UIView { readonly View _view; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Renderer is owned by the container view and cleared in Dispose(bool).")] IPlatformViewHandler _renderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Platform view is owned as a UIKit subview and cleared in Dispose(bool).")] UIView _platformView; bool _disposed; double _measuredHeight; + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is unsubscribed by ShellFlyoutLayoutManager.TearDown when the header view is released.")] internal event EventHandler HeaderSizeChanged; public UIContainerView(View view) diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs index f5d9ec00e7fd..5ae4f21166e6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs @@ -46,6 +46,7 @@ internal TableView TableView } #pragma warning disable CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ModelChanged handler only reloads the weak PlatformView reference for this table source lifetime.")] public TableViewModelRenderer(TableView model) #pragma warning restore CS0618 // Type or member is obsolete { diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs index b2ac7b8f44fd..879846a0e7fa 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.ComponentModel; using Microsoft.Maui.Controls.Platform; @@ -14,6 +15,7 @@ public class TableViewRenderer : ViewRenderer #pragma warning restore CS0618 // Type or member is obsolete { const int DefaultRowHeight = 44; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Original background view is retained so UpdateBackgroundView can restore the UITableView background.")] UIView _originalBackgroundView; RectangleF _previousFrame; From 7fd07b6aaec508d73ff0c95ea157cccf1a9b7a03 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:14:10 +0200 Subject: [PATCH 06/26] Resolve MemoryAnalyzers findings: Shell section/flyout/item renderers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Shell/iOS/ShellFlyoutContentRenderer.cs | 69 ++++++++++++++++++- .../Handlers/Shell/iOS/ShellFlyoutRenderer.cs | 29 ++++++++ .../Handlers/Shell/iOS/ShellItemRenderer.cs | 14 +++- .../Shell/iOS/ShellSectionRenderer.cs | 58 +++++++++++----- .../Shell/iOS/ShellSectionRootHeader.cs | 14 +++- .../Shell/iOS/ShellSectionRootRenderer.cs | 17 +++++ 6 files changed, 178 insertions(+), 23 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index 906f7c1df6d0..35a2c6548842 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; @@ -12,17 +13,38 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility public class ShellFlyoutContentRenderer : UIViewController, IShellFlyoutContentRenderer { CGSize _previousBounds; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The blur view is owned by this renderer and released in Dispose.")] UIVisualEffectView _blurView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The background image view is owned by this renderer and released in Dispose.")] UIImageView _bgImage; - readonly IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the renderer lifetime and released after Shell.PropertyChanged is unsubscribed in Dispose.")] + IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The header container is owned by this renderer and disposed when replaced or in Dispose.")] UIContainerView _headerView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The footer container is owned by this renderer and disposed when replaced or in Dispose.")] UIContainerView _footerView; View _footer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The table view controller is a child controller owned by this renderer and released in Dispose.")] ShellTableViewController _tableViewController; ShellFlyoutLayoutManager _shellFlyoutContentManager; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The view ordering cache only references renderer-owned subviews and is cleared in Dispose.")] UIView[] _uIViews; - public event EventHandler WillAppear; - public event EventHandler WillDisappear; + event EventHandler WillAppear; + event EventHandler WillDisappear; + + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "IShellFlyoutContentRenderer exposes this lifecycle event, so the explicit interface event must remain available to interface consumers.")] + event EventHandler IShellFlyoutContentRenderer.WillAppear + { + add => WillAppear += value; + remove => WillAppear -= value; + } + + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "IShellFlyoutContentRenderer exposes this lifecycle event, so the explicit interface event must remain available to interface consumers.")] + event EventHandler IShellFlyoutContentRenderer.WillDisappear + { + add => WillDisappear += value; + remove => WillDisappear -= value; + } const short HeaderIndex = 0; const short FooterIndex = 1; @@ -46,6 +68,7 @@ protected virtual ShellTableViewController CreateShellTableViewController() return new ShellTableViewController(_shellContext, OnElementSelected); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Dispose before the shell context is released.")] protected virtual void HandleShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.IsOneOf( @@ -137,6 +160,7 @@ void UpdateFlyoutFooter(View view) { var oldRenderer = (IPlatformViewHandler)_footer.Handler; var oldFooterView = _footerView; + _footer.MeasureInvalidated -= OnFooterMeasureInvalidated; _tableViewController.FooterView = null; _footerView?.Disconnect(); _footerView = null; @@ -206,6 +230,7 @@ void AddViewInCorrectOrder(UIView newView, int previousIndex) View.AddSubview(newView); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The footer MeasureInvalidated subscription is removed when the footer is replaced and in Dispose.")] void OnFooterMeasureInvalidated(object sender, System.EventArgs e) { ReMeasureFooter(); @@ -404,5 +429,43 @@ void OnElementSelected(Element element) { ((IShellController)_shellContext.Shell).OnFlyoutItemSelected(element); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (_shellContext?.Shell is not null) + _shellContext.Shell.PropertyChanged -= HandleShellPropertyChanged; + + if (_footer is not null) + _footer.MeasureInvalidated -= OnFooterMeasureInvalidated; + + _headerView?.Disconnect(); + _footerView?.Disconnect(); + _headerView?.RemoveFromSuperview(); + _footerView?.RemoveFromSuperview(); + _blurView?.RemoveFromSuperview(); + _bgImage?.RemoveFromSuperview(); + _bgImage?.Image?.Dispose(); + _bgImage?.Dispose(); + _blurView?.Dispose(); + _headerView?.Dispose(); + _footerView?.Dispose(); + _tableViewController?.RemoveFromParentViewController(); + _tableViewController?.Dispose(); + } + + _bgImage = null; + _blurView = null; + _headerView = null; + _footerView = null; + _footer = null; + _tableViewController = null; + _shellFlyoutContentManager = null; + _shellContext = null; + _uIViews = null; + + base.Dispose(disposing); + } } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs index ec7254f689cb..0ddcd38c45a0 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs @@ -1,6 +1,7 @@ #nullable disable using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreAnimation; using CoreGraphics; using Foundation; @@ -50,6 +51,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) public override UIStatusBarAnimation PreferredStatusBarUpdateAnimation => Detail.PreferredStatusBarUpdateAnimation; + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "AttachFlyout subscriptions are torn down in Dispose: Shell.PropertyChanged is unsubscribed and the pan gesture recognizer is removed and disposed.")] void IShellFlyoutRenderer.AttachFlyout(IShellContext context, UIViewController content) { Context = context; @@ -128,6 +130,7 @@ void IFlyoutBehaviorObserver.OnFlyoutBehaviorChanged(FlyoutBehavior behavior) FlyoutBehavior _flyoutBehavior; bool _gestureActive; bool _isOpen; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout animation is stopped and cleared when replaced, when completed, and in Dispose.")] UIViewPropertyAnimator _flyoutAnimation; Brush _backdropBrush; bool _layoutOccured; @@ -150,10 +153,13 @@ public IShellFlyoutTransition FlyoutTransition SlideFlyoutTransition SlideFlyoutTransition { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the flyout renderer lifetime and released in Dispose after observers are removed.")] IShellContext Context { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The detail controller is owned by the flyout renderer while attached and released in Dispose.")] UIViewController Detail { get; set; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout content renderer is owned by this renderer and released in Dispose.")] IShellFlyoutContentRenderer Flyout { get; set; } bool IsOpen @@ -203,12 +209,14 @@ void UpdateFlyoutAccessibility() Detail.View.AccessibilityElementsHidden = detailsElementsHidden; } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The pan gesture recognizer is attached to this renderer's view and removed and disposed in Dispose.")] UIPanGestureRecognizer PanGestureRecognizer { get; set; } Shell Shell { get; set; } IShellController ShellController => Shell; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The tap-off view is owned by this renderer and removed and disposed in Dispose.")] UIView TapoffView { get; set; } public override void ViewDidLayoutSubviews() @@ -270,6 +278,24 @@ protected override void Dispose(bool disposing) Shell.PropertyChanged -= OnShellPropertyChanged; ((IShellController)Shell).RemoveFlyoutBehaviorObserver(this); + _flyoutAnimation?.StopAnimation(true); + _flyoutAnimation = null; + if (PanGestureRecognizer != null) + { + View?.RemoveGestureRecognizer(PanGestureRecognizer); + PanGestureRecognizer.Dispose(); + PanGestureRecognizer = null; + } + RemoveTapoffView(); + if (Flyout?.ViewController is UIViewController flyoutController) + { + flyoutController.View?.RemoveFromSuperview(); + flyoutController.RemoveFromParentViewController(); + flyoutController.Dispose(); + } + Flyout = null; + _flyoutTransition = null; + SlideFlyoutTransition = null; Context = null; Shell = null; Detail = null; @@ -277,6 +303,7 @@ protected override void Dispose(bool disposing) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Dispose before the shell reference is released.")] protected virtual void OnShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == Shell.FlyoutIsPresentedProperty.PropertyName) @@ -362,6 +389,7 @@ void AddTapoffView() TapoffView.AddGestureRecognizer(recognizer); } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The flyout transition is retained for the renderer lifetime and cleared in Dispose.")] private IShellFlyoutTransition _flyoutTransition; public UIView NativeView => throw new NotImplementedException(); @@ -514,6 +542,7 @@ void RemoveTapoffView() return; TapoffView.RemoveFromSuperview(); + TapoffView.Dispose(); TapoffView = null; } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs index b96be43ad7d1..8ad18e1067b4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CoreGraphics; using Foundation; @@ -15,6 +16,7 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility { public class ShellItemRenderer : UITabBarController, IShellItemRenderer, IAppearanceObserver, IUINavigationControllerDelegate, IDisconnectable { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "This is a shared static Array.Empty cache and is not renderer-owned NSObject state.")] readonly static UITableViewCell[] EmptyUITableViewCellArray = Array.Empty(); #region IShellItemRenderer @@ -47,8 +49,10 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) #endregion IAppearanceObserver - readonly IShellContext _context; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the tab renderer lifetime and released in Dispose after observers are removed.")] + IShellContext _context; readonly Dictionary _sectionRenderers = new Dictionary(); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The appearance tracker is owned by this renderer and disposed in Dispose.")] IShellTabBarAppearanceTracker _appearanceTracker; ShellSection _currentSection; Page _displayedPage; @@ -56,6 +60,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) ShellItem _shellItem; static UIColor _defaultMoreTextLabelTextColor; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The current section renderer is tracked while selected and cleared in Dispose or when removed.")] internal IShellSectionRenderer CurrentRenderer { get; private set; } public ShellItemRenderer(IShellContext context) @@ -251,6 +256,9 @@ protected override void Dispose(bool disposing) _sectionRenderers.Clear(); CurrentRenderer = null; + _appearanceTracker?.Dispose(); + _appearanceTracker = null; + _context = null; _shellItem = null; _currentSection = null; _displayedPage = null; @@ -259,6 +267,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellItem.PropertyChanged subscription is removed in Disconnect before the shell item is released.")] protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == ShellItem.CurrentItemProperty.PropertyName) @@ -267,6 +276,7 @@ protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEv } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellItemController.ItemsCollectionChanged subscription is removed in Disconnect before the shell item is released.")] protected virtual void OnItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { if (e.OldItems != null) @@ -337,6 +347,7 @@ protected virtual void OnShellItemSet(ShellItem shellItem) ShellItemController.ItemsCollectionChanged += OnItemsCollectionChanged; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "Each ShellSection.PropertyChanged subscription is removed in Disconnect and RemoveRenderer.")] protected virtual void OnShellSectionPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == BaseShellItem.IsEnabledProperty.PropertyName) @@ -524,6 +535,7 @@ void OnDisplayedPageChanged(Page page) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The displayed page PropertyChanged subscription is removed in Disconnect and when the displayed page changes.")] void OnDisplayedPagePropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == Shell.TabBarIsVisibleProperty.PropertyName) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs index d0344d343a41..097a22c84458 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using System.Windows.Input; @@ -69,11 +70,13 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) #endregion IAppearanceObserver + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the section renderer lifetime and released in Dispose after shell event subscriptions are removed.")] IShellContext _context; readonly Dictionary _trackers = new Dictionary(); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The navigation bar appearance tracker is owned by this renderer and disposed in Dispose.")] IShellNavBarAppearanceTracker _appearanceTracker; Dictionary> _completionTasks = @@ -83,6 +86,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) bool _disposed; bool _firstLayoutCompleted; TaskCompletionSource _popCompletionTask; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The root renderer is owned by this section renderer and disposed in Dispose.")] IShellSectionRootRenderer _renderer; ShellSection _shellSection; bool _ignorePopCall; @@ -102,6 +106,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) // ViewControllers = ViewControllers.Remove(vc1) // ViewControllers = ViewControllers.Remove(vc2) // You've now added vc1 back because the second call to ViewControllers will still return a ViewControllers list with vc1 in it + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The pending view controller snapshot is transient navigation state and is cleared after navigation operations and in Dispose.")] UIViewController[] _pendingViewControllers; public ShellSectionRenderer(IShellContext context) : base(typeof(MauiNavigationBar), null) @@ -306,7 +311,7 @@ public override void ViewDidLoad() return; base.ViewDidLoad(); - InteractivePopGestureRecognizer.Delegate = new GestureDelegate(this, ShouldPop); + InteractivePopGestureRecognizer.Delegate = new GestureDelegate(this); UpdateFlowDirection(); } @@ -373,6 +378,7 @@ protected override void Dispose(bool disposing) _shellSection = null; _appearanceTracker = null; _renderer = null; + _pendingViewControllers = null; _context = null; base.Dispose(disposing); @@ -391,12 +397,14 @@ public virtual UIImage GetSecondaryToolbarMenuButtonImage() return UIImage.GetSystemImage("ellipsis.circle"); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Disconnect before the shell context is released.")] protected virtual void HandleShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.Is(VisualElement.FlowDirectionProperty)) UpdateFlowDirection(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSection.PropertyChanged subscription is removed in Disconnect before the shell section is released.")] protected virtual void HandlePropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == BaseShellItem.TitleProperty.PropertyName) @@ -460,6 +468,7 @@ protected virtual void OnInsertRequested(NavigationRequestedEventArgs e) InsertViewController(ActiveViewControllers().IndexOf(beforeRenderer.ViewController), renderer.ViewController); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSectionController.NavigationRequested subscription is removed in Disconnect before the shell section is released.")] protected virtual void OnNavigationRequested(object sender, NavigationRequestedEventArgs e) { switch (e.RequestType) @@ -638,6 +647,7 @@ Element ElementForViewController(UIViewController viewController) return null; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The displayed page PropertyChanged subscription is removed in Disconnect and when the displayed page changes.")] void OnDisplayedPagePropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == Shell.NavBarIsVisibleProperty.PropertyName) @@ -648,11 +658,13 @@ void OnDisplayedPagePropertyChanged(object sender, PropertyChangedEventArgs e) // We only care about using pendingViewControllers when we are setting the ViewControllers array directly // So, once navigation starts again (or ends) we can just clear the pendingViewControllers + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.Navigating subscription is removed in Disconnect before the shell context is released.")] void OnNavigating(object sender, ShellNavigatingEventArgs e) { _pendingViewControllers = null; } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.Navigated subscription is removed in Disconnect before the shell context is released.")] void OnNavigated(object sender, ShellNavigatedEventArgs e) { _pendingViewControllers = null; @@ -810,30 +822,31 @@ void UpdateShadowImages() class GestureDelegate : UIGestureRecognizerDelegate { - readonly UINavigationController _parent; - readonly Func _shouldPop; + readonly WeakReference _parent; - public GestureDelegate(UINavigationController parent, Func shouldPop) + public GestureDelegate(ShellSectionRenderer parent) { - _parent = parent; - _shouldPop = shouldPop; + _parent = new(parent); } public override bool ShouldBegin(UIGestureRecognizer recognizer) { - if ((_parent as ShellSectionRenderer).ActiveViewControllers().Length == 1) + if (!_parent.TryGetTarget(out var parent)) + return false; + + if (parent.ActiveViewControllers().Length == 1) return false; - return _shouldPop(); + return parent.ShouldPop(); } } class NavDelegate : UINavigationControllerDelegate { - readonly ShellSectionRenderer _self; + readonly WeakReference _self; public NavDelegate(ShellSectionRenderer renderer) { - _self = renderer; + _self = new(renderer); } // This is currently working around a Mono Interpreter bug @@ -848,10 +861,13 @@ public NavDelegate(ShellSectionRenderer renderer) public override void DidShowViewController(UINavigationController navigationController, [Transient] UIViewController viewController, bool animated) { + if (!_self.TryGetTarget(out var self)) + return; + (navigationController.NavigationBar as MauiNavigationBar)?.RefreshIfNeeded(); - var tasks = _self._completionTasks; - var popTask = _self._popCompletionTask; + var tasks = self._completionTasks; + var popTask = self._popCompletionTask; if (tasks.TryGetValue(viewController, out var source)) { @@ -866,14 +882,17 @@ public override void DidShowViewController(UINavigationController navigationCont public override void WillShowViewController(UINavigationController navigationController, [Transient] UIViewController viewController, bool animated) { - var element = _self.ElementForViewController(viewController); + if (!_self.TryGetTarget(out var self)) + return; + + var element = self.ElementForViewController(viewController); bool navBarVisible = false; if (element is not null) { if (element is ShellSection) - navBarVisible = _self._renderer.ShowNavBar; + navBarVisible = self._renderer.ShowNavBar; else navBarVisible = Shell.GetNavBarIsVisible(element); @@ -892,8 +911,8 @@ public override void WillShowViewController(UINavigationController navigationCon // Because the back button title needs to be set on the previous VC // We want to set the BackButtonItem as early as possible so there is no flickering - var currentPage = _self._context?.Shell?.GetCurrentShellPage(); - var trackers = _self._trackers; + var currentPage = self._context?.Shell?.GetCurrentShellPage(); + var trackers = self._trackers; if (currentPage?.Handler is IPlatformViewHandler pvh && pvh.ViewController == viewController && trackers.TryGetValue(currentPage, out var tracker) && @@ -914,8 +933,11 @@ void OnInteractionChanged(IUIViewControllerTransitionCoordinatorContext context) { if (!context.IsCancelled) { - _self._popCompletionTask = new TaskCompletionSource(); - _self.SendPoppedOnCompletion(_self._popCompletionTask.Task); + if (!_self.TryGetTarget(out var self)) + return; + + self._popCompletionTask = new TaskCompletionSource(); + self.SendPoppedOnCompletion(self._popCompletionTask.Task); } } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs index 9c85f14cff9a..e6a11ec31765 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootHeader.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreGraphics; using Foundation; using Microsoft.Maui.Graphics; @@ -56,8 +57,11 @@ void SetValues(Color backgroundColor, Color foregroundColor, Color unselectedCol static readonly NSString CellId = new NSString("HeaderCell"); - readonly IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the header lifetime and released when the header is disposed.")] + IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The selection bar view is owned by this header and disposed in Dispose.")] UIView _bar; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The bottom shadow view is owned by this header and disposed in Dispose.")] UIView _bottomShadow; Color _selectedColor; Color _unselectedColor; @@ -228,9 +232,13 @@ protected override void Dispose(bool disposing) ShellSection = null; _bar.RemoveFromSuperview(); + _bottomShadow.RemoveFromSuperview(); this.RemoveFromParentViewController(); _bar.Dispose(); + _bottomShadow.Dispose(); _bar = null; + _bottomShadow = null; + _shellContext = null; } _isDisposed = true; @@ -262,6 +270,7 @@ protected void LayoutBar() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSection.PropertyChanged subscription is removed in Dispose before the shell section is released.")] protected virtual void OnShellSectionPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == ShellSection.CurrentItemProperty.PropertyName) @@ -285,6 +294,7 @@ protected virtual void UpdateSelectedIndex(bool animated = false) CollectionView.SelectItem(NSIndexPath.FromItemSection((int)SelectedIndex, 0), false, UICollectionViewScrollPosition.CenteredHorizontally); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSectionController.ItemsCollectionChanged subscription is removed in Dispose before the shell section is released.")] void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs e) { HandleEventsOnItemsChange(e); @@ -310,6 +320,7 @@ void HandleEventsOnItemsChange(NotifyCollectionChangedEventArgs e) } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ShellContent.PropertyChanged subscriptions are removed in Dispose and when items are removed from the section.")] void OnShellContentPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ShellContent.Title)) @@ -374,6 +385,7 @@ public override bool Selected } } + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The label is owned by the collection view cell ContentView for the cell lifetime.")] public UILabel Label { get; } public override void LayoutSubviews() diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index e331f75a96aa..fed8f5f3edf3 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreAnimation; using CoreGraphics; using Foundation; @@ -23,20 +24,27 @@ public class ShellSectionRootRenderer : UIViewController, IShellSectionRootRende #endregion IShellSectionRootRenderer internal const int HeaderHeight = 35; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the root renderer lifetime and released in Dispose after Shell.PropertyChanged is unsubscribed.")] IShellContext _shellContext; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The blur view is owned by this renderer and removed when the renderer is disposed.")] UIView _blurView; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The container view is owned by this renderer and removed when the renderer is disposed.")] UIView _containerArea; ShellContent _currentContent; int _currentIndex = 0; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The header renderer is owned by this renderer and disposed in Dispose or when hidden.")] IShellSectionRootHeader _header; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The outgoing page handler is retained only during animation and cleared after the transition or in Dispose.")] IPlatformViewHandler _isAnimatingOut; Dictionary _renderers = new Dictionary(); + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The page renderer tracker is owned by this renderer and disposed in Dispose.")] IShellPageRendererTracker _tracker; bool _didLayoutSubviews; int _lastTabThickness = Int32.MinValue; Thickness _lastInset; bool _isDisposed; bool _isRotating; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The page animation is stopped and cleared in Disconnect and whenever a replacement animation starts.")] UIViewPropertyAnimator _pageAnimation; UIEdgeInsets _additionalSafeArea = UIEdgeInsets.Zero; @@ -182,6 +190,8 @@ protected override void Dispose(bool disposing) _header?.Dispose(); _tracker?.Dispose(); + _blurView?.RemoveFromSuperview(); + _containerArea?.RemoveFromSuperview(); foreach (var renderer in _renderers) { @@ -209,6 +219,10 @@ protected override void Dispose(bool disposing) _header = null; _tracker = null; _currentContent = null; + _isAnimatingOut = null; + _blurView = null; + _containerArea = null; + _pageAnimation = null; _isDisposed = true; } @@ -302,12 +316,14 @@ protected virtual void LoadRenderers() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Disconnect and Dispose before the shell context is released.")] protected virtual void HandleShellPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.Is(VisualElement.FlowDirectionProperty)) UpdateFlowDirection(); } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSection.PropertyChanged subscription is removed in Disconnect before the shell section is released.")] protected virtual void OnShellSectionPropertyChanged(object sender, PropertyChangedEventArgs e) { if (_isDisposed) @@ -510,6 +526,7 @@ void UpdateFlowDirection() } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellSectionController.ItemsCollectionChanged subscription is removed in Disconnect before the shell section is released.")] void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs e) { if (_isDisposed) From 5a3f7ecdc106a18931a4b6a422d81d98d94aede1 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:00:51 +0200 Subject: [PATCH 07/26] [Tests] Verify Shell renderer does not leak after navigation Adds a device memory test that verifies the iOS ShellSectionRenderer and the Shell renderer tree are collected after teardown, exercising the GestureDelegate/NavDelegate WeakReference back-references introduced when enabling MemoryAnalyzers on Controls.Core. Follows up on review feedback requesting tests to confirm the [UnconditionalSuppressMessage] annotations are not real leaks. Verified on iPhone 11 Pro (iOS 26.1) simulator: test passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DeviceTests/Elements/Shell/ShellTests.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs index 9b2c887e8a1f..19f49e82b870 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs @@ -958,6 +958,52 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => await AssertionExtensions.WaitForGC(pageReference); } +#if IOS || MACCATALYST + // Verifies that the iOS ShellSectionRenderer (a UINavigationController) and the Shell + // renderer tree are collected after teardown. ShellSectionRenderer installs UIKit delegates + // (GestureDelegate on the interactive pop gesture recognizer and NavDelegate on itself) that + // used to hold a strong back-reference to the renderer, creating a self-retain cycle that + // rooted the entire Shell renderer tree (the delegate held the renderer, which held its + // IShellContext -> Shell). Those back-references are now WeakReference, + // so pushing/popping a page (which exercises both delegates) must not keep the Shell alive. + [Fact(DisplayName = "Shell Renderer Does Not Leak After Navigation")] + public async Task ShellRendererDoesNotLeakAfterNavigation() + { + SetupBuilder(); + + WeakReference shellReference = null; + WeakReference handlerReference = null; + WeakReference platformViewReference = null; + + { + var shell = await CreateShellAsync(shell => + { + shell.CurrentItem = new ContentPage() { Title = "Page 1" }; + }); + + await CreateHandlerAndAddToWindow(shell, async (handler) => + { + await OnLoadedAsync(shell.CurrentPage); + + // Push and pop a page to install and exercise the ShellSectionRenderer's + // GestureDelegate and NavDelegate (the source of the former retain cycle). + var page = new ContentPage { Title = "Page 2", Content = new Label() }; + await shell.Navigation.PushAsync(page); + await OnLoadedAsync(page); + await shell.Navigation.PopAsync(); + + shellReference = new WeakReference(shell); + handlerReference = new WeakReference(handler); + platformViewReference = new WeakReference(((IElementHandler)handler).PlatformView); + }); + + shell = null; + } + + await AssertionExtensions.WaitForGC(shellReference, handlerReference, platformViewReference); + } +#endif + class LeakyShellPage : ContentPage { public LeakyShellPage() From edddbf588d18b2e49cf8817ecfc1a01ea3251230 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:40:04 +0200 Subject: [PATCH 08/26] Handle new MemoryAnalyzers findings from main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main surfaced two new MEM findings on iOS/MacCatalyst: - CarouselViewController2._orientationObserver (MEM0002) — the NSNotificationCenter observer token is removed in TearDown and Dispose. - ShellSectionRootRenderer.OnShellContentPropertyChanged (MEM0003) — the ShellContent.PropertyChanged subscriptions are removed in Disconnect and when items change. Both are backed by real teardown, so justified [UnconditionalSuppressMessage] annotations are added, matching the PR's convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs | 1 + .../src/Core/Handlers/Items2/iOS/CarouselViewController2.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index 3406dfc8cb23..8d563d75070c 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -589,6 +589,7 @@ void OnShellSectionItemsChanged(object sender, NotifyCollectionChangedEventArgs } } + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ShellContent.PropertyChanged subscriptions are removed in Disconnect and when items change before the shell section is released.")] void OnShellContentPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { if (_isDisposed) diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs index d97a09dec9d5..8a671a55c87f 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/CarouselViewController2.cs @@ -21,6 +21,7 @@ public class CarouselViewController2 : ItemsViewController2 bool _wasDetachedFromWindow = false; CarouselViewLoopManager _carouselViewLoopManager; CancellationTokenSource _scrollDebounce; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The orientation observer token is removed from NSNotificationCenter in TearDown and Dispose, so it does not root this controller.")] NSObject _orientationObserver; // We need to keep track of the old views to update the visual states From 508cc38a332bfeb7dac48b36da172d0877b961a5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:13:21 +0200 Subject: [PATCH 09/26] Fix MemoryAnalyzers lifecycle issues Restore shipped compatibility APIs, route Shell cleanup through handler disconnection, harden Apple teardown, and unsubscribe replaced TableView sources. Add focused Shell delegate and TableView subscription regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../ListView/iOS/CellTableViewCell.cs | 6 +- .../ListView/iOS/ContextScrollViewDelegate.cs | 14 ++-- .../Shell/iOS/ShellFlyoutContentRenderer.cs | 64 ++++++++++--------- .../Handlers/Shell/iOS/ShellFlyoutRenderer.cs | 57 ++++++++--------- .../Handlers/Shell/iOS/ShellRenderer.cs | 45 ++++++++----- .../Shell/iOS/ShellSectionRenderer.cs | 8 +-- .../Shell/iOS/ShellSectionRootRenderer.cs | 2 + .../TableView/iOS/TableViewModelRenderer.cs | 23 ++++++- .../TableView/iOS/TableViewRenderer.cs | 22 ++++++- .../iOS/NativeViewPropertyListener.cs | 10 ++- .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 11 ++-- .../net-maccatalyst/PublicAPI.Unshipped.txt | 11 ++-- .../DeviceTests/Elements/Shell/ShellTests.cs | 46 ------------- .../Elements/Shell/ShellTests.iOS.cs | 52 ++++++++++++++- .../tests/DeviceTests/Memory/MemoryTests.cs | 42 ++++++++++++ 15 files changed, 261 insertions(+), 152 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs index eb4564b2fc0b..49f58e7d26c2 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/CellTableViewCell.cs @@ -1,7 +1,7 @@ #nullable disable using System; -using System.Diagnostics.CodeAnalysis; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls.Compatibility; using ObjCRuntime; using UIKit; @@ -49,7 +49,7 @@ public Cell Cell else { PropertyChanged = null; - _cell = null; + _cell = null; } } } @@ -122,6 +122,8 @@ protected override void Dispose(bool disposing) if (disposing) { + PropertyChanged = null; + #pragma warning disable CS0618 // Type or member is obsolete if (Cell is Cell cell) { diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs index 89ad588e834f..74c3456c473b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextScrollViewDelegate.cs @@ -1,7 +1,7 @@ #nullable disable using System; -using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using ObjCRuntime; using UIKit; using NSAction = System.Action; @@ -225,6 +225,7 @@ protected override void Dispose(bool disposing) if (_closer != null) { + _closer.View?.RemoveGestureRecognizer(_closer); _closer.Dispose(); _closer = null; } @@ -252,11 +253,14 @@ void ClearCloserRecognizer(ContextActionsCell cell) if (_globalCloser == null || _globalCloser.State == UIGestureRecognizerState.Cancelled) return; - cell?.ContentCell?.RemoveGestureRecognizer(_closer); - _closer.Dispose(); - _closer = null; + if (_closer is not null) + { + cell?.ContentCell?.RemoveGestureRecognizer(_closer); + _closer.Dispose(); + _closer = null; + } - _table.RemoveGestureRecognizer(_globalCloser); + _table?.RemoveGestureRecognizer(_globalCloser); _table = null; _globalCloser.Dispose(); _globalCloser = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index 35a2c6548842..dc106af6667a 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -29,22 +29,13 @@ public class ShellFlyoutContentRenderer : UIViewController, IShellFlyoutContentR ShellFlyoutLayoutManager _shellFlyoutContentManager; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The view ordering cache only references renderer-owned subviews and is cleared in Dispose.")] UIView[] _uIViews; - event EventHandler WillAppear; - event EventHandler WillDisappear; + bool _isDisposed; - [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "IShellFlyoutContentRenderer exposes this lifecycle event, so the explicit interface event must remain available to interface consumers.")] - event EventHandler IShellFlyoutContentRenderer.WillAppear - { - add => WillAppear += value; - remove => WillAppear -= value; - } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event subscribers are cleared in Dispose(bool).")] + public event EventHandler WillAppear; - [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "IShellFlyoutContentRenderer exposes this lifecycle event, so the explicit interface event must remain available to interface consumers.")] - event EventHandler IShellFlyoutContentRenderer.WillDisappear - { - add => WillDisappear += value; - remove => WillDisappear -= value; - } + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event subscribers are cleared in Dispose(bool).")] + public event EventHandler WillDisappear; const short HeaderIndex = 0; const short FooterIndex = 1; @@ -303,9 +294,12 @@ protected virtual void UpdateBackground() void UpdateFlyoutBgImageAsync() { + if (_isDisposed || _shellContext?.Shell is not Shell shell || _bgImage is null) + return; + // Image - var imageSource = _shellContext.Shell.FlyoutBackgroundImage; - if (imageSource is null || !_shellContext.Shell.IsSet(Shell.FlyoutBackgroundImageProperty)) + var imageSource = shell.FlyoutBackgroundImage; + if (imageSource is null || !shell.IsSet(Shell.FlyoutBackgroundImageProperty)) { _bgImage.RemoveFromSuperview(); _bgImage.Image?.Dispose(); @@ -317,40 +311,41 @@ void UpdateFlyoutBgImageAsync() if (mauiContext is null) return; + var bgImage = _bgImage; imageSource.LoadImage(mauiContext, result => { - var nativeImage = result?.Value; - - if (View is null || nativeImage is null) + if (_isDisposed) return; - int previousIndex = GetPreviousIndex(_bgImage); + var nativeImage = result?.Value; + var view = ViewIfLoaded; if (nativeImage is null || - _shellContext.Shell.FlyoutBackgroundImage != imageSource) + view is null || + !ReferenceEquals(bgImage, _bgImage) || + _shellContext?.Shell is not Shell currentShell || + !ReferenceEquals(imageSource, currentShell.FlyoutBackgroundImage)) { - _bgImage?.RemoveFromSuperview(); return; } - _bgImage.Image = nativeImage; - switch (_shellContext.Shell.FlyoutBackgroundImageAspect) + int previousIndex = GetPreviousIndex(bgImage); + bgImage.Image = nativeImage; + switch (currentShell.FlyoutBackgroundImageAspect) { default: case Aspect.AspectFit: - _bgImage.ContentMode = UIViewContentMode.ScaleAspectFit; + bgImage.ContentMode = UIViewContentMode.ScaleAspectFit; break; case Aspect.AspectFill: - _bgImage.ContentMode = UIViewContentMode.ScaleAspectFill; + bgImage.ContentMode = UIViewContentMode.ScaleAspectFill; break; case Aspect.Fill: - _bgImage.ContentMode = UIViewContentMode.ScaleToFill; + bgImage.ContentMode = UIViewContentMode.ScaleToFill; break; } - if (_bgImage.Superview != View) - { - AddViewInCorrectOrder(_bgImage, previousIndex); - } + if (!_isDisposed && ReferenceEquals(bgImage, _bgImage) && bgImage.Superview != view) + AddViewInCorrectOrder(bgImage, previousIndex); }); } @@ -432,6 +427,11 @@ void OnElementSelected(Element element) protected override void Dispose(bool disposing) { + if (_isDisposed) + return; + + _isDisposed = true; + if (disposing) { if (_shellContext?.Shell is not null) @@ -453,6 +453,8 @@ protected override void Dispose(bool disposing) _footerView?.Dispose(); _tableViewController?.RemoveFromParentViewController(); _tableViewController?.Dispose(); + WillAppear = null; + WillDisappear = null; } _bgImage = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs index 0ddcd38c45a0..ec80dd735042 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs @@ -265,42 +265,39 @@ public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTr protected override void Dispose(bool disposing) { - base.Dispose(disposing); - - if (disposing) + if (disposing && !_disposed) { - if (!_disposed) - { - ShellController.RemoveAppearanceObserver(this); + ShellController.RemoveAppearanceObserver(this); - _disposed = true; + _disposed = true; - Shell.PropertyChanged -= OnShellPropertyChanged; - ((IShellController)Shell).RemoveFlyoutBehaviorObserver(this); + Shell.PropertyChanged -= OnShellPropertyChanged; + ((IShellController)Shell).RemoveFlyoutBehaviorObserver(this); - _flyoutAnimation?.StopAnimation(true); - _flyoutAnimation = null; - if (PanGestureRecognizer != null) - { - View?.RemoveGestureRecognizer(PanGestureRecognizer); - PanGestureRecognizer.Dispose(); - PanGestureRecognizer = null; - } - RemoveTapoffView(); - if (Flyout?.ViewController is UIViewController flyoutController) - { - flyoutController.View?.RemoveFromSuperview(); - flyoutController.RemoveFromParentViewController(); - flyoutController.Dispose(); - } - Flyout = null; - _flyoutTransition = null; - SlideFlyoutTransition = null; - Context = null; - Shell = null; - Detail = null; + _flyoutAnimation?.StopAnimation(true); + _flyoutAnimation = null; + if (PanGestureRecognizer != null) + { + View?.RemoveGestureRecognizer(PanGestureRecognizer); + PanGestureRecognizer.Dispose(); + PanGestureRecognizer = null; } + RemoveTapoffView(); + if (Flyout?.ViewController is UIViewController flyoutController) + { + flyoutController.View?.RemoveFromSuperview(); + flyoutController.RemoveFromParentViewController(); + flyoutController.Dispose(); + } + Flyout = null; + _flyoutTransition = null; + SlideFlyoutTransition = null; + Context = null; + Shell = null; + Detail = null; } + + base.Dispose(disposing); } [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The Shell.PropertyChanged subscription is removed in Dispose before the shell reference is released.")] diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index b994df51670e..165d3677f58d 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -1,7 +1,7 @@ #nullable disable using System; -using System.Diagnostics.CodeAnalysis; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Maui.Controls.Platform; @@ -210,32 +210,44 @@ protected virtual IShellTabBarAppearanceTracker CreateTabBarAppearanceTracker() protected override void Dispose(bool disposing) { + if (disposing) + DisconnectHandler(); + base.Dispose(disposing); + } - if (disposing && !_disposed) - { - _disposed = true; + void DisconnectHandler() + { + if (_disposed) + return; - if (Element != null) - Element.PropertyChanged -= OnElementPropertyChanged; + _disposed = true; - ElementChanged = null; + var element = Element; + if (element != null) + element.PropertyChanged -= OnElementPropertyChanged; - if (!ReferenceEquals(_incomingRenderer, _currentShellItemRenderer)) - { - (_incomingRenderer as IDisconnectable)?.Disconnect(); - _incomingRenderer?.Dispose(); - } + ElementChanged = null; - (_currentShellItemRenderer as IDisconnectable)?.Disconnect(); - _currentShellItemRenderer?.Dispose(); - FlyoutRenderer?.Dispose(); + if (!ReferenceEquals(_incomingRenderer, _currentShellItemRenderer)) + { + (_incomingRenderer as IDisconnectable)?.Disconnect(); + _incomingRenderer?.Dispose(); } + (_currentShellItemRenderer as IDisconnectable)?.Disconnect(); + _currentShellItemRenderer?.Dispose(); + _flyoutRenderer?.Dispose(); + _incomingRenderer = null; _currentShellItemRenderer = null; + _flyoutRenderer = null; _mauiContext = null; - FlyoutRenderer = null; + + if (element is IElement shell && ReferenceEquals(shell.Handler, this)) + shell.Handler = null; + + Element = null; } protected virtual async void OnCurrentItemChanged() @@ -446,6 +458,7 @@ void IElementHandler.Invoke(string command, object args) void IElementHandler.DisconnectHandler() { + DisconnectHandler(); } } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs index c0fa581a4641..c3a6bd645647 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs @@ -834,7 +834,7 @@ public GestureDelegate(ShellSectionRenderer parent) public override bool ShouldBegin(UIGestureRecognizer recognizer) { - if (!_parent.TryGetTarget(out var parent)) + if (!_parent.TryGetTarget(out var parent) || parent._disposed) return false; if (parent.ActiveViewControllers().Length == 1) @@ -864,7 +864,7 @@ public NavDelegate(ShellSectionRenderer renderer) public override void DidShowViewController(UINavigationController navigationController, [Transient] UIViewController viewController, bool animated) { - if (!_self.TryGetTarget(out var self)) + if (!_self.TryGetTarget(out var self) || self._disposed) return; (navigationController.NavigationBar as MauiNavigationBar)?.RefreshIfNeeded(); @@ -885,7 +885,7 @@ public override void DidShowViewController(UINavigationController navigationCont public override void WillShowViewController(UINavigationController navigationController, [Transient] UIViewController viewController, bool animated) { - if (!_self.TryGetTarget(out var self)) + if (!_self.TryGetTarget(out var self) || self._disposed) return; var element = self.ElementForViewController(viewController); @@ -936,7 +936,7 @@ void OnInteractionChanged(IUIViewControllerTransitionCoordinatorContext context) { if (!context.IsCancelled) { - if (!_self.TryGetTarget(out var self)) + if (!_self.TryGetTarget(out var self) || self._disposed) return; self._popCompletionTask = new TaskCompletionSource(); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index 8d563d75070c..a6c030c58ca6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -199,7 +199,9 @@ protected override void Dispose(bool disposing) _header?.Dispose(); _tracker?.Dispose(); _blurView?.RemoveFromSuperview(); + _blurView?.Dispose(); _containerArea?.RemoveFromSuperview(); + _containerArea?.Dispose(); foreach (var renderer in _renderers) { diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs index 5ae4f21166e6..dff8e6078b21 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs @@ -46,17 +46,20 @@ internal TableView TableView } #pragma warning disable CS0618 // Type or member is obsolete - [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "ModelChanged handler only reloads the weak PlatformView reference for this table source lifetime.")] + [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ModelChanged subscription is removed in Dispose(bool).")] public TableViewModelRenderer(TableView model) #pragma warning restore CS0618 // Type or member is obsolete { TableView = model; - model.ModelChanged += (s, e) => PlatformView?.ReloadData(); + model.ModelChanged += OnModelChanged; AutomaticallyDeselect = true; } public bool AutomaticallyDeselect { get; set; } + void OnModelChanged(object sender, EventArgs e) => + PlatformView?.ReloadData(); + public override UITableViewCell GetCell(UITableView tableView, NSIndexPath indexPath) { #pragma warning disable CS0618 // Type or member is obsolete @@ -190,6 +193,22 @@ void Tap(UITapGestureRecognizer gesture) { gesture.View.EndEditing(true); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { +#pragma warning disable CS0618 // Type or member is obsolete + if (TableView is TableView tableView) + tableView.ModelChanged -= OnModelChanged; +#pragma warning restore CS0618 // Type or member is obsolete + + PlatformView = null; + TableView = null; + } + + base.Dispose(disposing); + } } public class UnEvenTableViewModelRenderer : TableViewModelRenderer diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs index 879846a0e7fa..8e662e115b82 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs @@ -1,8 +1,8 @@ #nullable disable using System; -using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; using UIKit; @@ -45,6 +45,8 @@ protected override void Dispose(bool disposing) { if (disposing) { + DisposeSource(Control); + var viewsToLookAt = new Stack(Subviews); while (viewsToLookAt.Count > 0) { @@ -83,7 +85,11 @@ protected override void OnElementChanged(ElementChangedEventArgs e) if (Control == null || Control.Style != style) { - Control?.Dispose(); + if (Control is not null) + { + DisposeSource(Control); + Control.Dispose(); + } var tv = CreateNativeControl(); _originalBackgroundView = tv.BackgroundView; @@ -146,7 +152,19 @@ public override void TraitCollectionDidChange(UITraitCollection previousTraitCol void SetSource() { var modeledView = Element; + var previousSource = Control.Source; Control.Source = modeledView.HasUnevenRows ? new UnEvenTableViewModelRenderer(modeledView) : new TableViewModelRenderer(modeledView); + previousSource?.Dispose(); + } + + static void DisposeSource(UITableView tableView) + { + if (tableView is null) + return; + + var source = tableView.Source; + tableView.Source = null; + source?.Dispose(); } void UpdateBackgroundView() diff --git a/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs b/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs index 1d4f42f1e160..d58136873d9f 100644 --- a/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs +++ b/src/Controls/src/Core/Compatibility/iOS/NativeViewPropertyListener.cs @@ -1,7 +1,6 @@ #nullable disable using System; using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using Foundation; #if __MOBILE__ @@ -20,8 +19,13 @@ public NativeViewPropertyListener(string targetProperty) TargetProperty = targetProperty; } - [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "PropertyChanged must be public to implement INotifyPropertyChanged for the native property listener.")] - public event PropertyChangedEventHandler PropertyChanged; + event PropertyChangedEventHandler PropertyChanged; + + event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged + { + add => PropertyChanged += value; + remove => PropertyChanged -= value; + } public override void ObserveValue(NSString keyPath, NSObject ofObject, NSDictionary change, IntPtr context) { diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 25c1b293624b..9c36eaab064e 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1,17 +1,20 @@ #nullable enable +Microsoft.Maui.Controls.Label.~Label() -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.EntryCellRenderer.EntryCellTableViewCell.Dispose(bool disposing) -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.TableViewModelRenderer.Dispose(bool disposing) -> void override Microsoft.Maui.Controls.Handlers.Items.MauiCollectionView.AddSubview(UIKit.UIView! view) -> void override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewController2.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.DisconnectHandler(UIKit.UIView platformView) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutContentRenderer.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDidAppear(bool animated) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.DidMoveToParentViewController(UIKit.UIViewController parent) -> void *REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void -override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void -~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void -override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void -Microsoft.Maui.Controls.Label.~Label() -> void +override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 25c1b293624b..9c36eaab064e 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,17 +1,20 @@ #nullable enable +Microsoft.Maui.Controls.Label.~Label() -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.EntryCellRenderer.EntryCellTableViewCell.Dispose(bool disposing) -> void +override Microsoft.Maui.Controls.Handlers.Compatibility.TableViewModelRenderer.Dispose(bool disposing) -> void override Microsoft.Maui.Controls.Handlers.Items.MauiCollectionView.AddSubview(UIKit.UIView! view) -> void override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewController2.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.DisconnectHandler(UIKit.UIView platformView) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.UpdateFlowDirection() -> void +override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutContentRenderer.Dispose(bool disposing) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.ViewDidAppear(bool animated) -> void ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.DidMoveToParentViewController(UIKit.UIViewController parent) -> void *REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellTableViewController.LoadView() -> void -override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void -~override Microsoft.Maui.Controls.Handlers.Items2.StructuredItemsViewController2.NumberOfSections(UIKit.UICollectionView collectionView) -> nint ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void -override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void +override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void -Microsoft.Maui.Controls.Label.~Label() -> void +override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs index c3e536e17bae..020969604cc4 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs @@ -958,52 +958,6 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => await AssertionExtensions.WaitForGC(pageReference); } -#if IOS || MACCATALYST - // Verifies that the iOS ShellSectionRenderer (a UINavigationController) and the Shell - // renderer tree are collected after teardown. ShellSectionRenderer installs UIKit delegates - // (GestureDelegate on the interactive pop gesture recognizer and NavDelegate on itself) that - // used to hold a strong back-reference to the renderer, creating a self-retain cycle that - // rooted the entire Shell renderer tree (the delegate held the renderer, which held its - // IShellContext -> Shell). Those back-references are now WeakReference, - // so pushing/popping a page (which exercises both delegates) must not keep the Shell alive. - [Fact(DisplayName = "Shell Renderer Does Not Leak After Navigation")] - public async Task ShellRendererDoesNotLeakAfterNavigation() - { - SetupBuilder(); - - WeakReference shellReference = null; - WeakReference handlerReference = null; - WeakReference platformViewReference = null; - - { - var shell = await CreateShellAsync(shell => - { - shell.CurrentItem = new ContentPage() { Title = "Page 1" }; - }); - - await CreateHandlerAndAddToWindow(shell, async (handler) => - { - await OnLoadedAsync(shell.CurrentPage); - - // Push and pop a page to install and exercise the ShellSectionRenderer's - // GestureDelegate and NavDelegate (the source of the former retain cycle). - var page = new ContentPage { Title = "Page 2", Content = new Label() }; - await shell.Navigation.PushAsync(page); - await OnLoadedAsync(page); - await shell.Navigation.PopAsync(); - - shellReference = new WeakReference(shell); - handlerReference = new WeakReference(handler); - platformViewReference = new WeakReference(((IElementHandler)handler).PlatformView); - }); - - shell = null; - } - - await AssertionExtensions.WaitForGC(shellReference, handlerReference, platformViewReference); - } -#endif - class LeakyShellPage : ContentPage { public LeakyShellPage() diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index e40cafcbc6ba..f1897501eb55 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; using CoreGraphics; @@ -130,7 +131,7 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => shell.CurrentItem = mainTab2; await OnFrameSetToNotEmpty(pageWithoutTopTabs.Content); var boundsWithoutTopTabs = pageWithoutTopTabs.Content.GetPlatformViewBounds(); - + // Both APIs should produce consistent results Assert.Equal(ShellSectionRootRenderer.HeaderHeight, (boundsWithTopTabs.Top - boundsWithoutTopTabs.Top), 1); @@ -436,11 +437,11 @@ public void UpdateLayout(UINavigationController controller) UIView titleView = Shell.GetTitleView(_context.Shell.CurrentPage)?.Handler?.PlatformView as UIView ?? Shell.GetTitleView(_context.Shell)?.Handler?.PlatformView as UIView; UIView parentView = GetParentByType(titleView, typeof(UIKit.UIControl)); - + if (parentView != null) { handler.PreviousFrame = parentView.Frame; - + // height constraint NSLayoutConstraint.Create(parentView, NSLayoutAttribute.Bottom, NSLayoutRelation.Equal, parentView.Superview, NSLayoutAttribute.Bottom, 1.0f, 0.0f).Active = true; NSLayoutConstraint.Create(parentView, NSLayoutAttribute.Top, NSLayoutRelation.Equal, parentView.Superview, NSLayoutAttribute.Top, 1.0f, 0.0f).Active = true; @@ -582,6 +583,51 @@ await CreateHandlerAndAddToWindow(shell, async (handler) => } #endif #endif + [Fact(DisplayName = "Shell Section Delegates Do Not Retain Renderer")] + public async Task ShellSectionDelegatesDoNotRetainRenderer() + { + var references = await InvokeOnMainThreadAsync(CreateShellSectionDelegateReferences); + + await AssertionExtensions.WaitForGC(references.Renderer); + GC.KeepAlive(references.NavigationDelegate); + GC.KeepAlive(references.GestureDelegate); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static ShellSectionDelegateReferences CreateShellSectionDelegateReferences() + { + var renderer = new ShellSectionRenderer(new TestShellContext()); + renderer.LoadViewIfNeeded(); + + var references = new ShellSectionDelegateReferences( + new WeakReference(renderer), + renderer.Delegate, + renderer.InteractivePopGestureRecognizer.Delegate); + + ((IDisconnectable)renderer).Disconnect(); + return references; + } + + readonly record struct ShellSectionDelegateReferences( + WeakReference Renderer, + object NavigationDelegate, + object GestureDelegate); + + sealed class TestShellContext : IShellContext + { + public bool AllowFlyoutGesture => true; + public IShellItemRenderer CurrentShellItemRenderer => null; + public Shell Shell { get; } = new(); + public IMauiContext MauiContext => null; + + public IShellNavBarAppearanceTracker CreateNavBarAppearanceTracker() => null; + public IShellPageRendererTracker CreatePageRendererTracker() => null; + public IShellFlyoutContentRenderer CreateShellFlyoutContentRenderer() => null; + public IShellSearchResultsRenderer CreateShellSearchResultsRenderer() => null; + public IShellSectionRenderer CreateShellSectionRenderer(ShellSection shellSection) => null; + public IShellTabBarAppearanceTracker CreateTabBarAppearanceTracker() => null; + } + async Task TapToSelect(ContentPage page) { var shellContent = page.Parent as ShellContent; diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index 1c5166f630a8..8438168b523c 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -455,6 +456,47 @@ sealed record ShapePointsLeakProbeResult( WeakReference PlatformViewReference, PointCollection RootedOriginalPoints); +#if IOS || MACCATALYST + [Fact("TableView Source Replacement Unsubscribes Previous Source")] + public async Task TableViewSourceReplacementUnsubscribesPreviousSource() + { + SetupBuilder(); + + await InvokeOnMainThreadAsync(() => + { +#pragma warning disable CS0618 // Type or member is obsolete + var tableView = new TableView(new TableRoot()); + var renderer = CreateHandler(tableView); +#pragma warning restore CS0618 // Type or member is obsolete + var platformView = (UIKit.UITableView)((IElementHandler)renderer).PlatformView; + var previousSource = platformView.Source; + var initialSubscribers = GetModelChangedSubscribers(tableView); + + Assert.NotNull(previousSource); + Assert.Single(initialSubscribers); +#pragma warning disable CS0618 // Type or member is obsolete + tableView.HasUnevenRows = true; +#pragma warning restore CS0618 // Type or member is obsolete + + var replacementSubscribers = GetModelChangedSubscribers(tableView); + Assert.NotSame(previousSource, platformView.Source); + Assert.Single(replacementSubscribers); + Assert.NotSame(initialSubscribers[0].Target, replacementSubscribers[0].Target); + + renderer.Dispose(); + }); + } + + static Delegate[] GetModelChangedSubscribers(object tableView) + { +#pragma warning disable CS0618 // Type or member is obsolete + var eventField = typeof(TableView).GetField("ModelChanged", BindingFlags.Instance | BindingFlags.NonPublic); +#pragma warning restore CS0618 // Type or member is obsolete + Assert.NotNull(eventField); + return (eventField.GetValue(tableView) as MulticastDelegate)?.GetInvocationList() ?? []; + } +#endif + [Theory("CollectionView Header/Footer Doesn't Leak")] [InlineData(typeof(CollectionView))] #if IOS || MACCATALYST From 264aa27875787c6370b0b24c2ab000fab6100430 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:48:14 +0200 Subject: [PATCH 10/26] Fix remaining Apple lifecycle leaks Unsubscribe disposed context-action cells and remove TableView source-owned gesture recognizers during replacement. Extend Apple memory regressions to cover both ownership paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../ListView/iOS/ContextActionCell.cs | 1 + .../TableView/iOS/TableViewModelRenderer.cs | 29 ++++++++++++---- .../tests/DeviceTests/Memory/MemoryTests.cs | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs index 8bc030533c3c..034073e650d8 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs @@ -304,6 +304,7 @@ protected override void Dispose(bool disposing) if (_cell != null) { + _cell.PropertyChanged -= OnCellPropertyChanged; if (_cell.HasContextActions) ((INotifyCollectionChanged)_cell.ContextActions).CollectionChanged -= OnContextItemsChanged; _cell = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs index dff8e6078b21..05f2496d0488 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewModelRenderer.cs @@ -30,6 +30,10 @@ public class TableViewModelRenderer : UITableViewSource #pragma warning disable CS0618 // Type or member is obsolete WeakReference _tableView; #pragma warning restore CS0618 // Type or member is obsolete + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Gesture is removed from its view and disposed in Dispose(bool).")] + UILongPressGestureRecognizer _longPressGestureRecognizer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Gesture is removed from its view and disposed in Dispose(bool).")] + UITapGestureRecognizer _tapGestureRecognizer; UITableView PlatformView { @@ -178,13 +182,17 @@ void BindGestures(UITableView tableview) HasBoundGestures = true; - var gesture = new UILongPressGestureRecognizer(LongPress); - gesture.MinimumPressDuration = 2; - tableview.AddGestureRecognizer(gesture); + _longPressGestureRecognizer = new UILongPressGestureRecognizer(LongPress) + { + MinimumPressDuration = 2 + }; + tableview.AddGestureRecognizer(_longPressGestureRecognizer); - var dismissGesture = new UITapGestureRecognizer(Tap); - dismissGesture.CancelsTouchesInView = false; - tableview.AddGestureRecognizer(dismissGesture); + _tapGestureRecognizer = new UITapGestureRecognizer(Tap) + { + CancelsTouchesInView = false + }; + tableview.AddGestureRecognizer(_tapGestureRecognizer); PlatformView = tableview; } @@ -203,6 +211,15 @@ protected override void Dispose(bool disposing) tableView.ModelChanged -= OnModelChanged; #pragma warning restore CS0618 // Type or member is obsolete + _longPressGestureRecognizer?.View?.RemoveGestureRecognizer(_longPressGestureRecognizer); + _longPressGestureRecognizer?.Dispose(); + _longPressGestureRecognizer = null; + + _tapGestureRecognizer?.View?.RemoveGestureRecognizer(_tapGestureRecognizer); + _tapGestureRecognizer?.Dispose(); + _tapGestureRecognizer = null; + HasBoundGestures = false; + PlatformView = null; TableView = null; } diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index 8438168b523c..1971020f62c1 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -474,19 +474,45 @@ await InvokeOnMainThreadAsync(() => Assert.NotNull(previousSource); Assert.Single(initialSubscribers); + previousSource.NumberOfSections(platformView); + var previousSourceGestureCount = platformView.GestureRecognizers?.Length ?? 0; #pragma warning disable CS0618 // Type or member is obsolete tableView.HasUnevenRows = true; #pragma warning restore CS0618 // Type or member is obsolete var replacementSubscribers = GetModelChangedSubscribers(tableView); + var replacementSource = platformView.Source; + replacementSource.NumberOfSections(platformView); Assert.NotSame(previousSource, platformView.Source); Assert.Single(replacementSubscribers); Assert.NotSame(initialSubscribers[0].Target, replacementSubscribers[0].Target); + Assert.Equal(previousSourceGestureCount, platformView.GestureRecognizers?.Length ?? 0); renderer.Dispose(); }); } + [Fact("ContextActionsCell Disposal Unsubscribes Cell")] + public async Task ContextActionsCellDisposalUnsubscribesCell() + { + await InvokeOnMainThreadAsync(() => + { +#pragma warning disable CS0618 // Type or member is obsolete + var cell = new ViewCell(); + cell.ContextActions.Add(new MenuItem { Text = "Action" }); +#pragma warning restore CS0618 // Type or member is obsolete + using var tableView = new UIKit.UITableView(); + using var nativeCell = new UIKit.UITableViewCell(); + var contextActionsCell = new ContextActionsCell(); + + contextActionsCell.Update(tableView, cell, nativeCell); + Assert.Contains(GetPropertyChangedSubscribers(cell), subscriber => ReferenceEquals(subscriber.Target, contextActionsCell)); + + contextActionsCell.Dispose(); + Assert.DoesNotContain(GetPropertyChangedSubscribers(cell), subscriber => ReferenceEquals(subscriber.Target, contextActionsCell)); + }); + } + static Delegate[] GetModelChangedSubscribers(object tableView) { #pragma warning disable CS0618 // Type or member is obsolete @@ -495,6 +521,13 @@ static Delegate[] GetModelChangedSubscribers(object tableView) Assert.NotNull(eventField); return (eventField.GetValue(tableView) as MulticastDelegate)?.GetInvocationList() ?? []; } + + static Delegate[] GetPropertyChangedSubscribers(BindableObject bindable) + { + var eventField = typeof(BindableObject).GetField("PropertyChanged", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(eventField); + return (eventField.GetValue(bindable) as MulticastDelegate)?.GetInvocationList() ?? []; + } #endif [Theory("CollectionView Header/Footer Doesn't Leak")] From 417883bf6c284e96e87ce2b00fbe8448d1d02258 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:30:54 +0200 Subject: [PATCH 11/26] Guard Shell transitions during teardown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Handlers/Shell/iOS/ShellRenderer.cs | 76 ++++++++++++++--- .../Elements/Shell/ShellTests.iOS.cs | 83 +++++++++++++++++++ 2 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index 165d3677f58d..825ab0e65ac0 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -1,5 +1,6 @@ #nullable disable using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; @@ -102,6 +103,8 @@ IShellTabBarAppearanceTracker IShellContext.CreateTabBarAppearanceTracker() Task _activeTransition = Task.CompletedTask; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Incoming item renderer is a transient transition reference cleared when ShellRenderer is disposed.")] IShellItemRenderer _incomingRenderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Pending item renderers are disposed when superseded or when ShellRenderer disconnects.")] + readonly HashSet _pendingRenderers = new(ReferenceEqualityComparer.Instance); [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "MauiContext is provided by the handler and cleared when ShellRenderer is disposed.")] IMauiContext _mauiContext; @@ -229,14 +232,14 @@ void DisconnectHandler() ElementChanged = null; - if (!ReferenceEquals(_incomingRenderer, _currentShellItemRenderer)) + foreach (var pendingRenderer in _pendingRenderers) { - (_incomingRenderer as IDisconnectable)?.Disconnect(); - _incomingRenderer?.Dispose(); + if (!ReferenceEquals(pendingRenderer, _currentShellItemRenderer)) + DisconnectAndDispose(pendingRenderer); } - (_currentShellItemRenderer as IDisconnectable)?.Disconnect(); - _currentShellItemRenderer?.Dispose(); + _pendingRenderers.Clear(); + DisconnectAndDispose(_currentShellItemRenderer); _flyoutRenderer?.Dispose(); _incomingRenderer = null; @@ -250,6 +253,12 @@ void DisconnectHandler() Element = null; } + static void DisconnectAndDispose(IShellItemRenderer renderer) + { + (renderer as IDisconnectable)?.Disconnect(); + renderer?.Dispose(); + } + protected virtual async void OnCurrentItemChanged() { try @@ -264,7 +273,11 @@ protected virtual async void OnCurrentItemChanged() protected virtual async Task OnCurrentItemChangedAsync() { - var currentItem = Shell.CurrentItem; + var shell = Element as Shell; + if (_disposed || shell == null) + return; + + var currentItem = shell.CurrentItem; var oldLayer = _currentShellItemRenderer ?.ViewController @@ -275,6 +288,9 @@ protected virtual async Task OnCurrentItemChangedAsync() oldLayer.RemoveAllAnimations(); await _activeTransition; + if (_disposed) + return; + if (_currentShellItemRenderer?.ShellItem != currentItem) { var newController = CreateShellItemRenderer(currentItem); @@ -284,6 +300,9 @@ protected virtual async Task OnCurrentItemChangedAsync() protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e) { + if (_disposed) + return; + if (e.PropertyName == Shell.CurrentItemProperty.PropertyName) { OnCurrentItemChanged(); @@ -296,7 +315,7 @@ protected virtual void OnElementPropertyChanged(object sender, PropertyChangedEv void UpdateFlowDirection(bool readdViews = false) { - if (_currentShellItemRenderer?.ViewController == null) + if (_disposed || _currentShellItemRenderer?.ViewController == null) return; var originalValue = _currentShellItemRenderer.ViewController.View.SemanticContentAttribute; @@ -339,16 +358,37 @@ protected async void SetCurrentShellItemController(IShellItemRenderer value) protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value) { + if (_disposed) + { + DisconnectAndDispose(value); + return; + } + + _pendingRenderers.Add(value); _incomingRenderer = value; await _activeTransition; + if (_disposed) + { + if (_pendingRenderers.Remove(value)) + DisconnectAndDispose(value); + + return; + } + // This means the selected item changed while the active transition // was finishing up + var shell = Element as Shell; if (_incomingRenderer != value || - value.ShellItem != this.Shell.CurrentItem) + shell == null || + value.ShellItem != shell.CurrentItem) { - (value as IDisconnectable)?.Disconnect(); - value?.Dispose(); + if (ReferenceEquals(_incomingRenderer, value)) + _incomingRenderer = null; + + if (_pendingRenderers.Remove(value)) + DisconnectAndDispose(value); + return; } @@ -356,7 +396,9 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value (oldRenderer as IDisconnectable)?.Disconnect(); var newRenderer = value; + _pendingRenderers.Remove(value); _currentShellItemRenderer = value; + _incomingRenderer = null; AddChildViewController(newRenderer.ViewController); View.AddSubview(newRenderer.ViewController.View); @@ -371,6 +413,12 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value _activeTransition = transition.Transition(oldRenderer, newRenderer); await _activeTransition; + if (_disposed) + { + DisconnectAndDispose(oldRenderer); + return; + } + oldRenderer.ViewController.RemoveFromParentViewController(); oldRenderer.ViewController.View.RemoveFromSuperview(); oldRenderer.Dispose(); @@ -381,7 +429,7 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value } // current renderer is still valid - if (_currentShellItemRenderer == value) + if (!_disposed && _currentShellItemRenderer == value) { UpdateBackgroundColor(); UpdateFlowDirection(); @@ -390,6 +438,9 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value protected virtual void UpdateBackgroundColor() { + if (_disposed || Element == null) + return; + var color = Shell.BackgroundColor?.ToPlatform(); if (color == null) color = Microsoft.Maui.Platform.ColorExtensions.BackgroundColor; @@ -399,6 +450,9 @@ protected virtual void UpdateBackgroundColor() void SetupCurrentShellItem() { + if (_disposed) + return; + if (Shell.CurrentItem == null) { throw new InvalidOperationException("Active Shell Item not set. Have you added any Shell Items to your Shell?"); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index f1897501eb55..796902136850 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; @@ -588,11 +589,75 @@ public async Task ShellSectionDelegatesDoNotRetainRenderer() { var references = await InvokeOnMainThreadAsync(CreateShellSectionDelegateReferences); + Assert.NotNull(references.NavigationDelegate); + Assert.NotNull(references.GestureDelegate); await AssertionExtensions.WaitForGC(references.Renderer); GC.KeepAlive(references.NavigationDelegate); GC.KeepAlive(references.GestureDelegate); } + [Fact(DisplayName = "Disconnect Shell During Current Item Change Does Not Recreate Renderer")] + public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer() + { + SetupBuilder(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + shell.Items.Add(new ContentPage()); + }); + var handler = await InvokeOnMainThreadAsync(() => (ShellHandler)shell.ToHandler(MauiContext)); + + await InvokeOnMainThreadAsync(async () => + { + var activeTransitionField = typeof(ShellHandler).GetField("_activeTransition", BindingFlags.Instance | BindingFlags.NonPublic); + var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + var incomingRendererField = typeof(ShellHandler).GetField("_incomingRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(activeTransitionField); + Assert.NotNull(currentRendererField); + Assert.NotNull(incomingRendererField); + + var transition = new TaskCompletionSource(); + activeTransitionField.SetValue(handler, transition.Task); + + shell.CurrentItem = shell.Items[1]; + ((IElementHandler)handler).DisconnectHandler(); + transition.SetResult(true); + await Task.Yield(); + + Assert.Null(handler.Element); + Assert.Null(shell.Handler); + Assert.Null(currentRendererField.GetValue(handler)); + Assert.Null(incomingRendererField.GetValue(handler)); + }); + } + + [Fact(DisplayName = "Disconnect Shell Disposes All Pending Item Renderers")] + public Task DisconnectShellDisposesAllPendingItemRenderers() => + InvokeOnMainThreadAsync(async () => + { + using var handler = new TestableShellRenderer(); + var activeTransitionField = typeof(ShellHandler).GetField("_activeTransition", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(activeTransitionField); + + var transition = new TaskCompletionSource(); + activeTransitionField.SetValue(handler, transition.Task); + + var firstRenderer = new TrackedShellItemRenderer(); + var secondRenderer = new TrackedShellItemRenderer(); + var firstTransition = handler.SetCurrentShellItemControllerForTestAsync(firstRenderer); + var secondTransition = handler.SetCurrentShellItemControllerForTestAsync(secondRenderer); + + ((IElementHandler)handler).DisconnectHandler(); + transition.SetResult(true); + await Task.WhenAll(firstTransition, secondTransition); + + Assert.Equal(1, firstRenderer.DisconnectCount); + Assert.Equal(1, firstRenderer.DisposeCount); + Assert.Equal(1, secondRenderer.DisconnectCount); + Assert.Equal(1, secondRenderer.DisposeCount); + }); + [MethodImpl(MethodImplOptions.NoInlining)] static ShellSectionDelegateReferences CreateShellSectionDelegateReferences() { @@ -628,6 +693,24 @@ sealed class TestShellContext : IShellContext public IShellTabBarAppearanceTracker CreateTabBarAppearanceTracker() => null; } + sealed class TestableShellRenderer : ShellHandler + { + public Task SetCurrentShellItemControllerForTestAsync(IShellItemRenderer renderer) => + SetCurrentShellItemControllerAsync(renderer); + } + + sealed class TrackedShellItemRenderer : IShellItemRenderer, IDisconnectable + { + public int DisconnectCount { get; private set; } + public int DisposeCount { get; private set; } + public ShellItem ShellItem { get; set; } + public UIViewController ViewController => null; + + public void Disconnect() => DisconnectCount++; + + public void Dispose() => DisposeCount++; + } + async Task TapToSelect(ContentPage page) { var shellContent = page.Parent as ShellContent; From 45c9a2b7ddbb4d5011c7f60a00471b6a7f8bba3a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:58:07 +0200 Subject: [PATCH 12/26] Dispose stale Shell flyout images Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Shell/iOS/ShellFlyoutContentRenderer.cs | 17 ++++- .../DeviceTests/Elements/Shell/ShellTests.cs | 6 ++ .../Elements/Shell/ShellTests.iOS.cs | 68 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index dc106af6667a..6737a9760a50 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -17,6 +17,8 @@ public class ShellFlyoutContentRenderer : UIViewController, IShellFlyoutContentR UIVisualEffectView _blurView; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The background image view is owned by this renderer and released in Dispose.")] UIImageView _bgImage; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The background image result owns the current native image and is disposed when replaced or when this renderer is disposed.")] + IDisposable _bgImageResult; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The shell context is retained for the renderer lifetime and released after Shell.PropertyChanged is unsubscribed in Dispose.")] IShellContext _shellContext; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The header container is owned by this renderer and disposed when replaced or in Dispose.")] @@ -302,8 +304,9 @@ void UpdateFlyoutBgImageAsync() if (imageSource is null || !shell.IsSet(Shell.FlyoutBackgroundImageProperty)) { _bgImage.RemoveFromSuperview(); - _bgImage.Image?.Dispose(); _bgImage.Image = null; + _bgImageResult?.Dispose(); + _bgImageResult = null; return; } @@ -315,7 +318,10 @@ void UpdateFlyoutBgImageAsync() imageSource.LoadImage(mauiContext, result => { if (_isDisposed) + { + result?.Dispose(); return; + } var nativeImage = result?.Value; var view = ViewIfLoaded; @@ -325,11 +331,15 @@ view is null || _shellContext?.Shell is not Shell currentShell || !ReferenceEquals(imageSource, currentShell.FlyoutBackgroundImage)) { + result?.Dispose(); return; } int previousIndex = GetPreviousIndex(bgImage); + var previousResult = _bgImageResult; + _bgImageResult = result; bgImage.Image = nativeImage; + previousResult?.Dispose(); switch (currentShell.FlyoutBackgroundImageAspect) { default: @@ -446,7 +456,9 @@ protected override void Dispose(bool disposing) _footerView?.RemoveFromSuperview(); _blurView?.RemoveFromSuperview(); _bgImage?.RemoveFromSuperview(); - _bgImage?.Image?.Dispose(); + if (_bgImage is not null) + _bgImage.Image = null; + _bgImageResult?.Dispose(); _bgImage?.Dispose(); _blurView?.Dispose(); _headerView?.Dispose(); @@ -457,6 +469,7 @@ protected override void Dispose(bool disposing) WillDisappear = null; } + _bgImageResult = null; _bgImage = null; _blurView = null; _headerView = null; diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs index 020969604cc4..e8a9c16d3d48 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs @@ -36,6 +36,12 @@ void SetupBuilder() { EnsureHandlerCreated(builder => { +#if IOS || MACCATALYST + builder.ConfigureImageSources(services => + { + services.AddService(); + }); +#endif builder.ConfigureMauiHandlers(handlers => { SetupShellHandlers(handlers); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 796902136850..074afd363e7b 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -5,9 +5,11 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Text; +using System.Threading; using System.Threading.Tasks; using CoreGraphics; using Foundation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Handlers.Compatibility; using Microsoft.Maui.Controls.Platform; @@ -658,6 +660,36 @@ public Task DisconnectShellDisposesAllPendingItemRenderers() => Assert.Equal(1, secondRenderer.DisposeCount); }); + [Fact(DisplayName = "Stale Shell Flyout Background Image Is Disposed")] + public async Task StaleShellFlyoutBackgroundImageIsDisposed() + { + SetupBuilder(); + var imageSource = new DelayedImageSource(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + shell.FlyoutBackgroundImage = imageSource; + }); + var imageSourceServiceProvider = MauiContext.Services.GetRequiredService(); + var imageSourceService = imageSourceServiceProvider.GetRequiredImageSourceService(); + var delayedImageSourceService = Assert.IsType(imageSourceService); + + await CreateHandlerAndAddToWindow(shell, async _ => + { + try + { + Assert.True(await Task.Run(() => delayedImageSourceService.Starting.WaitOne(TimeSpan.FromSeconds(5)))); + shell.FlyoutBackgroundImage = null; + } + finally + { + delayedImageSourceService.DoWork.Set(); + } + + Assert.True(await Task.Run(() => delayedImageSourceService.Disposed.WaitOne(TimeSpan.FromSeconds(5)))); + }); + } + [MethodImpl(MethodImplOptions.NoInlining)] static ShellSectionDelegateReferences CreateShellSectionDelegateReferences() { @@ -711,6 +743,42 @@ sealed class TrackedShellItemRenderer : IShellItemRenderer, IDisconnectable public void Dispose() => DisposeCount++; } + interface IDelayedImageSource : IImageSource + { + } + + sealed class DelayedImageSource : ImageSource, IDelayedImageSource + { + } + + sealed class DelayedImageSourceService : IImageSourceService + { + public AutoResetEvent Starting { get; } = new(false); + public AutoResetEvent DoWork { get; } = new(false); + public AutoResetEvent Disposed { get; } = new(false); + + public Task> GetImageAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) => + GetImageAsync((IDelayedImageSource)imageSource, scale, cancellationToken); + + public async Task> GetImageAsync( + IDelayedImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + Starting.Set(); + await Task.Run(() => DoWork.WaitOne(), cancellationToken); + var image = await Microsoft.Maui.ApplicationModel.MainThread.InvokeOnMainThreadAsync(() => new UIImage()); + return new ImageSourceServiceResult(image, () => + { + image.Dispose(); + Disposed.Set(); + }); + } + } + async Task TapToSelect(ContentPage page) { var shellContent = page.Parent as ShellContent; From e759ebceea4cbe99d7d929cc955faf29fe0cd761 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:21:11 +0200 Subject: [PATCH 13/26] Fix late iOS lifecycle callbacks after disposal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../ListView/iOS/ContextActionCell.cs | 3 +-- .../Shell/iOS/ShellSectionRootRenderer.cs | 3 +++ .../Elements/Shell/ShellTests.iOS.cs | 23 +++++++++++++++++++ .../tests/DeviceTests/Memory/MemoryTests.cs | 7 +++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs index 034073e650d8..a97601f0419e 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ContextActionCell.cs @@ -59,7 +59,7 @@ public ContextActionsCell(string templateId) : base(UITableViewCellStyle.Default { } - [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Content cell is removed during updates and cleared in Dispose(bool).")] + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Content cell is replaced during updates and retained after disposal because UIKit can issue late layout and reuse callbacks.")] public UITableViewCell ContentCell { get; private set; } public bool IsOpen => ScrollDelegate.IsOpen; @@ -286,7 +286,6 @@ protected override void Dispose(bool disposing) } _tableView = null; - ContentCell = null; _moreButton?.Dispose(); _moreButton = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index a6c030c58ca6..1d923ccf2b95 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -69,6 +69,9 @@ public ShellSectionRootRenderer(ShellSection shellSection, IShellContext shellCo public override void ViewDidLayoutSubviews() { + if (_isDisposed) + return; + _didLayoutSubviews = true; base.ViewDidLayoutSubviews(); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 074afd363e7b..81d512d5ed40 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -598,6 +598,29 @@ public async Task ShellSectionDelegatesDoNotRetainRenderer() GC.KeepAlive(references.GestureDelegate); } + [Fact(DisplayName = "Disposed Shell Section Root Ignores Late Layout")] + public async Task DisposedShellSectionRootIgnoresLateLayout() + { + SetupBuilder(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + }); + + await CreateHandlerAndAddToWindow(shell, async handler => + { + await OnLoadedAsync(shell.CurrentPage); + + IShellContext shellContext = handler; + var shellItemRenderer = Assert.IsType(shellContext.CurrentShellItemRenderer); + var sectionRenderer = Assert.IsType(shellItemRenderer.CurrentRenderer); + var rootRenderer = Assert.IsType(sectionRenderer.ViewControllers[0]); + + rootRenderer.Dispose(); + rootRenderer.ViewDidLayoutSubviews(); + }); + } + [Fact(DisplayName = "Disconnect Shell During Current Item Change Does Not Recreate Renderer")] public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer() { diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index 1971020f62c1..216e06e30f99 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -502,7 +502,10 @@ await InvokeOnMainThreadAsync(() => cell.ContextActions.Add(new MenuItem { Text = "Action" }); #pragma warning restore CS0618 // Type or member is obsolete using var tableView = new UIKit.UITableView(); - using var nativeCell = new UIKit.UITableViewCell(); + using var nativeCell = new CellTableViewCell(UIKit.UITableViewCellStyle.Default, "ContextActionsCellTest") + { + Cell = cell + }; var contextActionsCell = new ContextActionsCell(); contextActionsCell.Update(tableView, cell, nativeCell); @@ -510,6 +513,8 @@ await InvokeOnMainThreadAsync(() => contextActionsCell.Dispose(); Assert.DoesNotContain(GetPropertyChangedSubscribers(cell), subscriber => ReferenceEquals(subscriber.Target, contextActionsCell)); + Assert.Same(cell, ((Microsoft.Maui.Controls.Compatibility.INativeElementView)contextActionsCell).Element); + contextActionsCell.SizeThatFits(new CoreGraphics.CGSize(100, 44)); }); } From cda4ac98906d1bc102c3470a72502cb475efed3d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:41:34 +0200 Subject: [PATCH 14/26] Fix remaining Apple lifecycle teardown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Handlers/Shell/iOS/ShellRenderer.cs | 4 +++ .../iOS/Extensions/ToolbarItemExtensions.cs | 3 +- .../Elements/Shell/ShellTests.iOS.cs | 36 +++++++++++++++++-- .../tests/DeviceTests/Memory/MemoryTests.cs | 21 +++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index 825ab0e65ac0..368229be9fdd 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -255,6 +255,10 @@ void DisconnectHandler() static void DisconnectAndDispose(IShellItemRenderer renderer) { + var viewController = renderer?.ViewController; + viewController?.ViewIfLoaded?.RemoveFromSuperview(); + viewController?.RemoveFromParentViewController(); + (renderer as IDisconnectable)?.Disconnect(); renderer?.Dispose(); } diff --git a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs index 9df445f0d08c..94eae5011f8a 100644 --- a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs +++ b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs @@ -314,7 +314,8 @@ protected override void Dispose(bool disposing) { if (disposing) { - ((SecondaryToolbarItemContent)CustomView).TouchUpInside -= OnClicked; + if (CustomView is SecondaryToolbarItemContent customView) + customView.TouchUpInside -= OnClicked; if (_item.TryGetTarget(out var item)) item.PropertyChanged -= OnPropertyChanged; diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 81d512d5ed40..a05cb6ca950d 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -630,10 +630,10 @@ public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer( shell.Items.Add(new ContentPage()); shell.Items.Add(new ContentPage()); }); - var handler = await InvokeOnMainThreadAsync(() => (ShellHandler)shell.ToHandler(MauiContext)); await InvokeOnMainThreadAsync(async () => { + using var handler = (ShellHandler)shell.ToHandler(MauiContext); var activeTransitionField = typeof(ShellHandler).GetField("_activeTransition", BindingFlags.Instance | BindingFlags.NonPublic); var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); var incomingRendererField = typeof(ShellHandler).GetField("_incomingRenderer", BindingFlags.Instance | BindingFlags.NonPublic); @@ -657,6 +657,28 @@ await InvokeOnMainThreadAsync(async () => }); } + [Fact(DisplayName = "Disconnect Shell Detaches Current Item Renderer Before Disposal")] + public Task DisconnectShellDetachesCurrentItemRendererBeforeDisposal() => + InvokeOnMainThreadAsync(() => + { + using var handler = new TestableShellRenderer(); + using var parentViewController = new UIViewController(); + var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(currentRendererField); + + var renderer = new TrackedShellItemRenderer(); + parentViewController.AddChildViewController(renderer.ViewController); + parentViewController.View.AddSubview(renderer.ViewController.View); + currentRendererField.SetValue(handler, renderer); + + ((IElementHandler)handler).DisconnectHandler(); + + Assert.Null(renderer.ParentAtDispose); + Assert.Null(renderer.SuperviewAtDispose); + Assert.Equal(1, renderer.DisconnectCount); + Assert.Equal(1, renderer.DisposeCount); + }); + [Fact(DisplayName = "Disconnect Shell Disposes All Pending Item Renderers")] public Task DisconnectShellDisposesAllPendingItemRenderers() => InvokeOnMainThreadAsync(async () => @@ -758,12 +780,20 @@ sealed class TrackedShellItemRenderer : IShellItemRenderer, IDisconnectable { public int DisconnectCount { get; private set; } public int DisposeCount { get; private set; } + public UIViewController ParentAtDispose { get; private set; } + public UIView SuperviewAtDispose { get; private set; } public ShellItem ShellItem { get; set; } - public UIViewController ViewController => null; + public UIViewController ViewController { get; } = new UIViewController(); public void Disconnect() => DisconnectCount++; - public void Dispose() => DisposeCount++; + public void Dispose() + { + ParentAtDispose = ViewController.ParentViewController; + SuperviewAtDispose = ViewController.ViewIfLoaded?.Superview; + DisposeCount++; + ViewController.Dispose(); + } } interface IDelayedImageSource : IImageSource diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index 216e06e30f99..08e3fe20b59b 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -518,6 +518,27 @@ await InvokeOnMainThreadAsync(() => }); } + [Fact("Secondary Toolbar Item Disposal Unsubscribes After Custom View Replacement")] + public async Task SecondaryToolbarItemDisposalUnsubscribesAfterCustomViewReplacement() + { + await InvokeOnMainThreadAsync(() => + { + var item = new ToolbarItem + { + Order = ToolbarItemOrder.Secondary + }; + var nativeItem = Microsoft.Maui.Controls.Compatibility.Platform.iOS.ToolbarItemExtensions.ToUIBarButtonItem(item); + using var replacementView = new UIKit.UIView(); + + Assert.Contains(GetPropertyChangedSubscribers(item), subscriber => ReferenceEquals(subscriber.Target, nativeItem)); + + nativeItem.CustomView = replacementView; + nativeItem.Dispose(); + + Assert.DoesNotContain(GetPropertyChangedSubscribers(item), subscriber => ReferenceEquals(subscriber.Target, nativeItem)); + }); + } + static Delegate[] GetModelChangedSubscribers(object tableView) { #pragma warning disable CS0618 // Type or member is obsolete From b657c384710de8d66cbdf24a9c3d671743bc7059 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:55:00 +0200 Subject: [PATCH 15/26] Dispose Shell section root native resources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Handlers/Shell/iOS/ShellSectionRootRenderer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs index 1d923ccf2b95..66a3aa96f89e 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRootRenderer.cs @@ -237,6 +237,8 @@ protected override void Dispose(bool disposing) _containerArea = null; _pageAnimation = null; _isDisposed = true; + + base.Dispose(disposing); } protected virtual void LayoutRenderers() From e7f32c3c84e91246670171398c0b828d33890bae Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:09:45 +0200 Subject: [PATCH 16/26] Track secondary toolbar content ownership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../iOS/Extensions/ToolbarItemExtensions.cs | 9 ++++++--- src/Controls/tests/DeviceTests/Memory/MemoryTests.cs | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs index 94eae5011f8a..d4ee46e0ddc1 100644 --- a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs +++ b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs @@ -281,16 +281,19 @@ void UpdateText(ToolbarItem item) sealed class SecondaryToolbarItem : UIBarButtonItem { + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "The content view is owned by this toolbar item and released after its TouchUpInside subscription is removed in Dispose.")] + SecondaryToolbarItemContent _content; readonly WeakReference _item; public SecondaryToolbarItem(ToolbarItem item) : base(new SecondaryToolbarItemContent()) { + _content = (SecondaryToolbarItemContent)CustomView; _item = new(item); UpdateText(item); UpdateIcon(item); UpdateIsEnabled(item); - ((SecondaryToolbarItemContent)CustomView).TouchUpInside += OnClicked; + _content.TouchUpInside += OnClicked; item.PropertyChanged += OnPropertyChanged; if (item != null && !string.IsNullOrEmpty(item.AutomationId)) @@ -314,8 +317,8 @@ protected override void Dispose(bool disposing) { if (disposing) { - if (CustomView is SecondaryToolbarItemContent customView) - customView.TouchUpInside -= OnClicked; + _content?.TouchUpInside -= OnClicked; + _content = null; if (_item.TryGetTarget(out var item)) item.PropertyChanged -= OnPropertyChanged; diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index 08e3fe20b59b..52ce72c8fc5b 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -528,14 +528,21 @@ await InvokeOnMainThreadAsync(() => Order = ToolbarItemOrder.Secondary }; var nativeItem = Microsoft.Maui.Controls.Compatibility.Platform.iOS.ToolbarItemExtensions.ToUIBarButtonItem(item); + using var originalContent = Assert.IsAssignableFrom(nativeItem.CustomView); using var replacementView = new UIKit.UIView(); + var activationCount = 0; + item.Command = new Command(() => activationCount++); Assert.Contains(GetPropertyChangedSubscribers(item), subscriber => ReferenceEquals(subscriber.Target, nativeItem)); + originalContent.SendActionForControlEvents(UIKit.UIControlEvent.TouchUpInside); + Assert.Equal(1, activationCount); nativeItem.CustomView = replacementView; nativeItem.Dispose(); Assert.DoesNotContain(GetPropertyChangedSubscribers(item), subscriber => ReferenceEquals(subscriber.Target, nativeItem)); + originalContent.SendActionForControlEvents(UIKit.UIControlEvent.TouchUpInside); + Assert.Equal(1, activationCount); }); } From 2228762c85878f8d9fddf49982ad2ecc507c3d34 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:04:55 +0200 Subject: [PATCH 17/26] Fix Shell transition teardown review findings Avoid disconnecting an outgoing Shell renderer twice when disposal races an active transition, and await the actual current-item change task in the regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Handlers/Shell/iOS/ShellRenderer.cs | 25 +++++++++++-------- .../DeviceTests/Elements/Shell/ShellTests.cs | 5 +++- .../Elements/Shell/ShellTests.iOS.cs | 15 ++++++++--- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index 368229be9fdd..36a0c6bdf676 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -254,13 +254,23 @@ void DisconnectHandler() } static void DisconnectAndDispose(IShellItemRenderer renderer) + { + DetachRenderer(renderer); + (renderer as IDisconnectable)?.Disconnect(); + renderer?.Dispose(); + } + + static void DetachAndDispose(IShellItemRenderer renderer) + { + DetachRenderer(renderer); + renderer?.Dispose(); + } + + static void DetachRenderer(IShellItemRenderer renderer) { var viewController = renderer?.ViewController; viewController?.ViewIfLoaded?.RemoveFromSuperview(); viewController?.RemoveFromParentViewController(); - - (renderer as IDisconnectable)?.Disconnect(); - renderer?.Dispose(); } protected virtual async void OnCurrentItemChanged() @@ -417,15 +427,10 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value _activeTransition = transition.Transition(oldRenderer, newRenderer); await _activeTransition; + DetachAndDispose(oldRenderer); + if (_disposed) - { - DisconnectAndDispose(oldRenderer); return; - } - - oldRenderer.ViewController.RemoveFromParentViewController(); - oldRenderer.ViewController.View.RemoveFromSuperview(); - oldRenderer.Dispose(); } else { diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs index e8a9c16d3d48..4ab85cc67e17 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs @@ -32,7 +32,7 @@ namespace Microsoft.Maui.DeviceTests [Collection(ControlsHandlerTestBase.RunInNewWindowCollection)] public partial class ShellTests : ControlsHandlerTestBase { - void SetupBuilder() + void SetupBuilder(Type shellHandlerType = null) { EnsureHandlerCreated(builder => { @@ -45,6 +45,9 @@ void SetupBuilder() builder.ConfigureMauiHandlers(handlers => { SetupShellHandlers(handlers); + if (shellHandlerType != null) + handlers.AddHandler(typeof(Shell), shellHandlerType); + handlers.AddHandler(typeof(NavigationPage), typeof(NavigationViewHandler)); handlers.AddHandler(typeof(Button), typeof(ButtonHandler)); handlers.AddHandler(typeof(Entry), typeof(EntryHandler)); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index a05cb6ca950d..5b928ea7b41f 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -624,7 +624,7 @@ await CreateHandlerAndAddToWindow(shell, async handler => [Fact(DisplayName = "Disconnect Shell During Current Item Change Does Not Recreate Renderer")] public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer() { - SetupBuilder(); + SetupBuilder(typeof(TestableShellRenderer)); var shell = await CreateShellAsync(shell => { shell.Items.Add(new ContentPage()); @@ -633,7 +633,7 @@ public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer( await InvokeOnMainThreadAsync(async () => { - using var handler = (ShellHandler)shell.ToHandler(MauiContext); + using var handler = (TestableShellRenderer)shell.ToHandler(MauiContext); var activeTransitionField = typeof(ShellHandler).GetField("_activeTransition", BindingFlags.Instance | BindingFlags.NonPublic); var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); var incomingRendererField = typeof(ShellHandler).GetField("_incomingRenderer", BindingFlags.Instance | BindingFlags.NonPublic); @@ -646,9 +646,11 @@ await InvokeOnMainThreadAsync(async () => activeTransitionField.SetValue(handler, transition.Task); shell.CurrentItem = shell.Items[1]; + var currentItemChanged = handler.CurrentItemChangedTask; + Assert.False(currentItemChanged.IsCompleted); ((IElementHandler)handler).DisconnectHandler(); transition.SetResult(true); - await Task.Yield(); + await currentItemChanged; Assert.Null(handler.Element); Assert.Null(shell.Handler); @@ -772,8 +774,15 @@ sealed class TestShellContext : IShellContext sealed class TestableShellRenderer : ShellHandler { + public Task CurrentItemChangedTask { get; private set; } = Task.CompletedTask; + public Task SetCurrentShellItemControllerForTestAsync(IShellItemRenderer renderer) => SetCurrentShellItemControllerAsync(renderer); + + protected override void OnCurrentItemChanged() + { + CurrentItemChangedTask = OnCurrentItemChangedAsync(); + } } sealed class TrackedShellItemRenderer : IShellItemRenderer, IDisconnectable From 96731ea3aecffa0cd3fa425f6d4de44350665aa1 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:43:33 +0200 Subject: [PATCH 18/26] Fix Shell flyout late lifecycle callbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Shell/iOS/ShellFlyoutContentRenderer.cs | 15 +++++++++++ .../Elements/Shell/ShellTests.iOS.cs | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index 6737a9760a50..15121c026216 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -261,6 +261,9 @@ void UpdateFooterPosition(nfloat footerHeight) public override void ViewWillLayoutSubviews() { + if (_isDisposed) + return; + base.ViewWillLayoutSubviews(); UpdateFooterPosition(); UpdateFlyoutContent(); @@ -363,6 +366,9 @@ view is null || public override void ViewDidLayoutSubviews() { + if (_isDisposed) + return; + base.ViewDidLayoutSubviews(); _tableViewController.LayoutParallax(); @@ -372,6 +378,9 @@ public override void ViewDidLayoutSubviews() public override void ViewDidLoad() { + if (_isDisposed) + return; + base.ViewDidLoad(); @@ -418,6 +427,9 @@ void UpdateFlyoutContent() public override void ViewWillAppear(bool animated) { + if (_isDisposed) + return; + UpdateFlowDirection(); base.ViewWillAppear(animated); WillAppear?.Invoke(this, EventArgs.Empty); @@ -425,6 +437,9 @@ public override void ViewWillAppear(bool animated) public override void ViewWillDisappear(bool animated) { + if (_isDisposed) + return; + base.ViewWillDisappear(animated); WillDisappear?.Invoke(this, EventArgs.Empty); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 5b928ea7b41f..00c75569b835 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -621,6 +621,33 @@ await CreateHandlerAndAddToWindow(shell, async handler => }); } + [Fact(DisplayName = "Disposed Shell Flyout Content Ignores Late Lifecycle Callbacks")] + public async Task DisposedShellFlyoutContentIgnoresLateLifecycleCallbacks() + { + SetupBuilder(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + }); + + await CreateHandlerAndAddToWindow(shell, handler => + { + var flyoutContent = handler.ViewController + .ChildViewControllers + .OfType() + .First(); + + flyoutContent.Dispose(); + flyoutContent.ViewDidLoad(); + flyoutContent.ViewWillAppear(false); + flyoutContent.ViewWillLayoutSubviews(); + flyoutContent.ViewDidLayoutSubviews(); + flyoutContent.ViewWillDisappear(false); + + return Task.CompletedTask; + }); + } + [Fact(DisplayName = "Disconnect Shell During Current Item Change Does Not Recreate Renderer")] public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer() { From 7ebf9340b3e85f547438929ea339e07a720197bd Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:44:36 +0200 Subject: [PATCH 19/26] Cancel Shell item transitions during teardown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Handlers/Shell/iOS/ShellItemTransition.cs | 2 +- .../Handlers/Shell/iOS/ShellRenderer.cs | 53 ++++++++++++++- .../Elements/Shell/ShellTests.iOS.cs | 67 +++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs index 44d650904b14..40cee13425ea 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellItemTransition.cs @@ -10,7 +10,7 @@ public class ShellItemTransition : IShellItemTransition { public Task Transition(IShellItemRenderer oldRenderer, IShellItemRenderer newRenderer) { - TaskCompletionSource task = new TaskCompletionSource(); + TaskCompletionSource task = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var oldView = oldRenderer.ViewController.View; var newView = newRenderer.ViewController.View; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index 36a0c6bdf676..8648c48676fa 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -101,8 +101,11 @@ IShellTabBarAppearanceTracker IShellContext.CreateTabBarAppearanceTracker() [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Flyout renderer is owned by ShellRenderer and disposed in Dispose(bool).")] IShellFlyoutRenderer _flyoutRenderer; Task _activeTransition = Task.CompletedTask; + TaskCompletionSource _activeTransitionCancellation; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Incoming item renderer is a transient transition reference cleared when ShellRenderer is disposed.")] IShellItemRenderer _incomingRenderer; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Outgoing item renderer is retained only while its transition is active and disposed when the transition completes or ShellRenderer disconnects.")] + IShellItemRenderer _outgoingRenderer; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Pending item renderers are disposed when superseded or when ShellRenderer disconnects.")] readonly HashSet _pendingRenderers = new(ReferenceEqualityComparer.Instance); [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "MauiContext is provided by the handler and cleared when ShellRenderer is disposed.")] @@ -231,6 +234,7 @@ void DisconnectHandler() element.PropertyChanged -= OnElementPropertyChanged; ElementChanged = null; + CancelActiveTransition(); foreach (var pendingRenderer in _pendingRenderers) { @@ -239,9 +243,19 @@ void DisconnectHandler() } _pendingRenderers.Clear(); + var outgoingRenderer = _outgoingRenderer; + _outgoingRenderer = null; + if (outgoingRenderer is not null && + !ReferenceEquals(outgoingRenderer, _currentShellItemRenderer)) + { + DetachAndDispose(outgoingRenderer); + } + DisconnectAndDispose(_currentShellItemRenderer); _flyoutRenderer?.Dispose(); + _activeTransition = Task.CompletedTask; + _activeTransitionCancellation = null; _incomingRenderer = null; _currentShellItemRenderer = null; _flyoutRenderer = null; @@ -273,6 +287,21 @@ static void DetachRenderer(IShellItemRenderer renderer) viewController?.RemoveFromParentViewController(); } + void CancelActiveTransition() + { + if (_activeTransition.IsCompleted) + return; + + _currentShellItemRenderer?.ViewController?.ViewIfLoaded?.Layer.RemoveAllAnimations(); + _activeTransitionCancellation?.TrySetResult(true); + } + + static async Task WaitForTransitionOrCancellationAsync(Task transition, Task cancellation) + { + var completedTask = await Task.WhenAny(transition, cancellation); + await completedTask; + } + protected virtual async void OnCurrentItemChanged() { try @@ -423,11 +452,29 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value if (oldRenderer != null) { var transition = CreateShellItemTransition(); + var transitionCancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _activeTransition = transition.Transition(oldRenderer, newRenderer); - await _activeTransition; + _activeTransitionCancellation = transitionCancellation; + _outgoingRenderer = oldRenderer; + _activeTransition = WaitForTransitionOrCancellationAsync( + transition.Transition(oldRenderer, newRenderer), + transitionCancellation.Task); - DetachAndDispose(oldRenderer); + try + { + await _activeTransition; + } + finally + { + if (ReferenceEquals(_activeTransitionCancellation, transitionCancellation)) + _activeTransitionCancellation = null; + + if (ReferenceEquals(_outgoingRenderer, oldRenderer)) + { + _outgoingRenderer = null; + DetachAndDispose(oldRenderer); + } + } if (_disposed) return; diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 00c75569b835..e8d395ffb858 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using CoreAnimation; using CoreGraphics; using Foundation; using Microsoft.Extensions.DependencyInjection; @@ -708,6 +709,46 @@ public Task DisconnectShellDetachesCurrentItemRendererBeforeDisposal() => Assert.Equal(1, renderer.DisposeCount); }); + [Fact(DisplayName = "Disconnect Shell Cancels Active Item Transition Before Disposal")] + public Task DisconnectShellCancelsActiveItemTransitionBeforeDisposal() => + InvokeOnMainThreadAsync(async () => + { + using var handler = new TestableShellRenderer(); + var currentRendererField = typeof(ShellHandler).GetField("_currentShellItemRenderer", BindingFlags.Instance | BindingFlags.NonPublic); + var elementProperty = typeof(ShellHandler).GetProperty(nameof(ShellHandler.Element), BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(currentRendererField); + Assert.NotNull(elementProperty); + + var shell = new Shell(); + shell.Items.Add(new ContentPage()); + shell.Items.Add(new ContentPage()); + shell.CurrentItem = shell.Items[1]; + elementProperty.SetValue(handler, shell); + + var oldRenderer = new TrackedShellItemRenderer { ShellItem = shell.Items[0] }; + var newRenderer = new TrackedShellItemRenderer { ShellItem = shell.Items[1] }; + var transition = new ControlledShellItemTransition(); + handler.ShellItemTransition = transition; + currentRendererField.SetValue(handler, oldRenderer); + + var setCurrentItem = handler.SetCurrentShellItemControllerForTestAsync(newRenderer); + await transition.Started.Task.WaitAsync(TimeSpan.FromSeconds(1)); + Assert.NotEmpty(newRenderer.ViewController.View.Layer.AnimationKeys); + + ((IElementHandler)handler).DisconnectHandler(); + + var completedTask = await Task.WhenAny(setCurrentItem, Task.Delay(TimeSpan.FromSeconds(1))); + transition.Complete(); + await setCurrentItem; + + Assert.Same(setCurrentItem, completedTask); + Assert.False(newRenderer.HadAnimationsAtDispose); + Assert.Equal(1, oldRenderer.DisconnectCount); + Assert.Equal(1, oldRenderer.DisposeCount); + Assert.Equal(1, newRenderer.DisconnectCount); + Assert.Equal(1, newRenderer.DisposeCount); + }); + [Fact(DisplayName = "Disconnect Shell Disposes All Pending Item Renderers")] public Task DisconnectShellDisposesAllPendingItemRenderers() => InvokeOnMainThreadAsync(async () => @@ -802,10 +843,14 @@ sealed class TestShellContext : IShellContext sealed class TestableShellRenderer : ShellHandler { public Task CurrentItemChangedTask { get; private set; } = Task.CompletedTask; + public IShellItemTransition ShellItemTransition { get; set; } public Task SetCurrentShellItemControllerForTestAsync(IShellItemRenderer renderer) => SetCurrentShellItemControllerAsync(renderer); + protected override IShellItemTransition CreateShellItemTransition() => + ShellItemTransition ?? base.CreateShellItemTransition(); + protected override void OnCurrentItemChanged() { CurrentItemChangedTask = OnCurrentItemChangedAsync(); @@ -816,6 +861,7 @@ sealed class TrackedShellItemRenderer : IShellItemRenderer, IDisconnectable { public int DisconnectCount { get; private set; } public int DisposeCount { get; private set; } + public bool HadAnimationsAtDispose { get; private set; } public UIViewController ParentAtDispose { get; private set; } public UIView SuperviewAtDispose { get; private set; } public ShellItem ShellItem { get; set; } @@ -825,6 +871,7 @@ sealed class TrackedShellItemRenderer : IShellItemRenderer, IDisconnectable public void Dispose() { + HadAnimationsAtDispose = ViewController.ViewIfLoaded?.Layer.AnimationKeys?.Length > 0; ParentAtDispose = ViewController.ParentViewController; SuperviewAtDispose = ViewController.ViewIfLoaded?.Superview; DisposeCount++; @@ -832,6 +879,26 @@ public void Dispose() } } + sealed class ControlledShellItemTransition : IShellItemTransition + { + readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Transition(IShellItemRenderer oldRenderer, IShellItemRenderer newRenderer) + { + using var animation = CABasicAnimation.FromKeyPath("opacity"); + animation.From = NSNumber.FromDouble(0); + animation.To = NSNumber.FromDouble(1); + animation.Duration = 60; + newRenderer.ViewController.View.Layer.AddAnimation(animation, "active-shell-item-transition"); + Started.TrySetResult(true); + return _completion.Task; + } + + public void Complete() => _completion.TrySetResult(true); + } + interface IDelayedImageSource : IImageSource { } From 436e010858ffb5ff122e210f71711a1eb279be79 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:49:40 +0200 Subject: [PATCH 20/26] Fix delayed image test cancellation race Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index e8d395ffb858..af5eeb201be6 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -925,7 +925,7 @@ public async Task> GetImageAsync( CancellationToken cancellationToken = default) { Starting.Set(); - await Task.Run(() => DoWork.WaitOne(), cancellationToken); + await Task.Run(() => DoWork.WaitOne()); var image = await Microsoft.Maui.ApplicationModel.MainThread.InvokeOnMainThreadAsync(() => new UIImage()); return new ImageSourceServiceResult(image, () => { From 6a3f85454766afa25fb33a4af61a52656d3a71b6 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:18:52 +0200 Subject: [PATCH 21/26] Guard secondary toolbar updates after custom view replacement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../iOS/Extensions/ToolbarItemExtensions.cs | 16 +++++++++++----- .../tests/DeviceTests/Memory/MemoryTests.cs | 5 +++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs index d4ee46e0ddc1..6078fbf09f60 100644 --- a/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs +++ b/src/Controls/src/Core/Compatibility/iOS/Extensions/ToolbarItemExtensions.cs @@ -329,7 +329,9 @@ protected override void Dispose(bool disposing) [UnconditionalSuppressMessage("Memory", "MEM0003", Justification = "The ToolbarItem PropertyChanged subscription is removed in Dispose.")] void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (!_item.TryGetTarget(out var item)) + if (!_item.TryGetTarget(out var item) || + _content is null || + !ReferenceEquals(CustomView, _content)) return; if (e.PropertyName == MenuItem.TextProperty.PropertyName) @@ -352,23 +354,27 @@ void UpdateIcon(ToolbarItem item) { item.IconImageSource.LoadImage(item.FindMauiContext(), result => { - ((SecondaryToolbarItemContent)CustomView).Image = ScaleImageToSystemDefaults(item.IconImageSource, result?.Value); + if (_content is not null) + _content.Image = ScaleImageToSystemDefaults(item.IconImageSource, result?.Value); }); } else { - ((SecondaryToolbarItemContent)CustomView).Image = null; + if (_content is not null) + _content.Image = null; } } void UpdateIsEnabled(ToolbarItem item) { - ((UIControl)CustomView).Enabled = item.IsEnabled; + if (_content is not null) + _content.Enabled = item.IsEnabled; } void UpdateText(ToolbarItem item) { - ((SecondaryToolbarItemContent)CustomView).Text = item.Text; + if (_content is not null) + _content.Text = item.Text; } sealed class SecondaryToolbarItemContent : UIControl diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index 52ce72c8fc5b..88e66013b4ac 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -538,6 +538,11 @@ await InvokeOnMainThreadAsync(() => Assert.Equal(1, activationCount); nativeItem.CustomView = replacementView; + item.Text = "Updated"; + item.IconImageSource = new FileImageSource(); + item.IsEnabled = false; + Assert.True(originalContent.Enabled); + Assert.Same(replacementView, nativeItem.CustomView); nativeItem.Dispose(); Assert.DoesNotContain(GetPropertyChangedSubscribers(item), subscriber => ReferenceEquals(subscriber.Target, nativeItem)); From 54a1b7ff1a15abd98964bb3e2eb35e1fd0591bf7 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:39:55 +0200 Subject: [PATCH 22/26] Make Shell flyout disposal idempotent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Handlers/Shell/iOS/ShellFlyoutRenderer.cs | 9 ++++++--- .../DeviceTests/Elements/Shell/ShellTests.iOS.cs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs index ec80dd735042..6cb0e8ea0424 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs @@ -265,12 +265,15 @@ public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTr protected override void Dispose(bool disposing) { - if (disposing && !_disposed) + if (_disposed) + return; + + _disposed = true; + + if (disposing) { ShellController.RemoveAppearanceObserver(this); - _disposed = true; - Shell.PropertyChanged -= OnShellPropertyChanged; ((IShellController)Shell).RemoveFlyoutBehaviorObserver(this); diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index af5eeb201be6..928bac03cb7d 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -649,6 +649,16 @@ await CreateHandlerAndAddToWindow(shell, handler => }); } + [Fact(DisplayName = "Shell Flyout Renderer Disposal Is Idempotent After Native Teardown")] + public Task ShellFlyoutRendererDisposalIsIdempotentAfterNativeTeardown() => + InvokeOnMainThreadAsync(() => + { + var renderer = new TestableShellFlyoutRenderer(); + + renderer.DisposeForTest(false); + renderer.DisposeForTest(true); + }); + [Fact(DisplayName = "Disconnect Shell During Current Item Change Does Not Recreate Renderer")] public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer() { @@ -899,6 +909,11 @@ public Task Transition(IShellItemRenderer oldRenderer, IShellItemRenderer newRen public void Complete() => _completion.TrySetResult(true); } + sealed class TestableShellFlyoutRenderer : ShellFlyoutRenderer + { + public void DisposeForTest(bool disposing) => base.Dispose(disposing); + } + interface IDelayedImageSource : IImageSource { } From c49b7e8614a8a4516542a1b19bf92b74250bc060 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:55:26 +0200 Subject: [PATCH 23/26] Disconnect Shell flyout container handlers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Shell/iOS/ShellFlyoutContentRenderer.cs | 6 +-- .../Handlers/Shell/iOS/UIContainerView.cs | 24 +++++++----- .../Elements/Shell/ShellTests.iOS.cs | 38 +++++++++++++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index 15121c026216..2e632bee426b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -151,17 +151,13 @@ void UpdateFlyoutFooter(View view) int previousIndex = GetPreviousIndex(_footerView); if (_footer is not null) { - var oldRenderer = (IPlatformViewHandler)_footer.Handler; var oldFooterView = _footerView; _footer.MeasureInvalidated -= OnFooterMeasureInvalidated; _tableViewController.FooterView = null; - _footerView?.Disconnect(); _footerView = null; _uIViews[FooterIndex] = null; oldFooterView?.RemoveFromSuperview(); - - _footer.Handler = null; - oldRenderer?.DisconnectHandler(); + oldFooterView?.Dispose(); } _footer = view; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs index 6cf86bf68421..480b559c5960 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs @@ -11,14 +11,14 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility public class UIContainerView : UIView { readonly View _view; - [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Renderer is owned by the container view and cleared in Dispose(bool).")] + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Renderer is owned by the container view and disconnected and cleared in Disconnect.")] IPlatformViewHandler _renderer; - [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Platform view is owned as a UIKit subview and cleared in Dispose(bool).")] + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Platform view is owned as a UIKit subview and detached and cleared in Disconnect.")] UIView _platformView; bool _disposed; double _measuredHeight; - [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is unsubscribed by ShellFlyoutLayoutManager.TearDown when the header view is released.")] + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event subscribers are cleared in Dispose(bool).")] internal event EventHandler HeaderSizeChanged; public UIContainerView(View view) @@ -105,7 +105,6 @@ public override CGSize SizeThatFits(CGSize size) public override void WillRemoveSubview(UIView uiview) { - Disconnect(); base.WillRemoveSubview(uiview); } @@ -141,6 +140,16 @@ public override void LayoutSubviews() internal void Disconnect() { + var renderer = _renderer; + var platformView = _platformView; + + _renderer = null; + _platformView = null; + + if (platformView?.Superview == this) + platformView.RemoveFromSuperview(); + + renderer?.DisconnectHandler(); } protected override void Dispose(bool disposing) @@ -151,12 +160,7 @@ protected override void Dispose(bool disposing) if (disposing) { Disconnect(); - - if (_platformView.Superview == this) - _platformView.RemoveFromSuperview(); - - _renderer = null; - _platformView = null; + HeaderSizeChanged = null; _disposed = true; } diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 928bac03cb7d..5166686641a5 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -649,6 +649,44 @@ await CreateHandlerAndAddToWindow(shell, handler => }); } + [Fact(DisplayName = "Disposed Shell Flyout Content Disconnects Header And Footer Handlers")] + public async Task DisposedShellFlyoutContentDisconnectsHeaderAndFooterHandlers() + { + SetupBuilder(); + var header = new Label { Text = "Header" }; + var footer = new Label { Text = "Footer" }; + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + shell.FlyoutHeader = header; + shell.FlyoutFooter = footer; + }); + + await CreateHandlerAndAddToWindow(shell, handler => + { + var flyoutContent = handler.ViewController + .ChildViewControllers + .OfType() + .First(); + var headerPlatformView = header.ToPlatform(); + var footerPlatformView = footer.ToPlatform(); + + Assert.NotNull(header.Handler); + Assert.NotNull(footer.Handler); + Assert.NotNull(headerPlatformView.Superview); + Assert.NotNull(footerPlatformView.Superview); + + flyoutContent.Dispose(); + + Assert.Null(header.Handler); + Assert.Null(footer.Handler); + Assert.Null(headerPlatformView.Superview); + Assert.Null(footerPlatformView.Superview); + + return Task.CompletedTask; + }); + } + [Fact(DisplayName = "Shell Flyout Renderer Disposal Is Idempotent After Native Teardown")] public Task ShellFlyoutRendererDisposalIsIdempotentAfterNativeTeardown() => InvokeOnMainThreadAsync(() => From 5ef9015c495e1617e167c44a83b893b5857b9409 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:23:08 +0200 Subject: [PATCH 24/26] Clear Shell table source subscribers on disposal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Shell/iOS/ShellTableViewController.cs | 5 ++- .../Shell/iOS/ShellTableViewSource.cs | 9 ++++- .../Elements/Shell/ShellTests.iOS.cs | 37 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs index 68282c8f53a6..45798a25c648 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewController.cs @@ -1,8 +1,8 @@ #nullable disable using System; -using System.Diagnostics.CodeAnalysis; using System.Collections.Specialized; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using CoreAnimation; using CoreGraphics; using ObjCRuntime; @@ -137,7 +137,10 @@ protected override void Dispose(bool disposing) ShellController.FlyoutItemsChanged -= OnFlyoutItemsChanged; if (_source != null) + { _source.ScrolledEvent -= OnScrolled; + _source.Disconnect(); + } ShellFlyoutContentManager.TearDown(); _onElementSelected = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs index 4ef611a98a19..a3b01a41f1eb 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellTableViewSource.cs @@ -1,7 +1,7 @@ #nullable disable using System; -using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Foundation; using Microsoft.Maui.Controls.Internals; using ObjCRuntime; @@ -26,9 +26,14 @@ public ShellTableViewSource(IShellContext context, Action onElementSele _onElementSelected = onElementSelected; } - [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Event is owned by ShellTableViewController and unsubscribed in ShellTableViewController.Dispose(bool).")] + [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "All event subscribers are cleared by Disconnect when ShellTableViewController is disposed.")] public event EventHandler ScrolledEvent; + internal void Disconnect() + { + ScrolledEvent = null; + } + public List> Groups { get diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 5166686641a5..9637a68bc027 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -687,6 +687,43 @@ await CreateHandlerAndAddToWindow(shell, handler => }); } + [Fact(DisplayName = "Disposed Shell Table Source Clears Scrolled Subscribers")] + public async Task DisposedShellTableSourceClearsScrolledSubscribers() + { + SetupBuilder(); + var shell = await CreateShellAsync(shell => + { + shell.Items.Add(new ContentPage()); + }); + + await CreateHandlerAndAddToWindow(shell, handler => + { + var flyoutContent = handler.ViewController + .ChildViewControllers + .OfType() + .First(); + var tableViewController = flyoutContent.ChildViewControllers + .OfType() + .First(); + var source = Assert.IsType(tableViewController.TableView.Source); + var scrolled = false; + source.ScrolledEvent += OnScrolled; + + flyoutContent.Dispose(); + + using var nativeScrollView = new UIScrollView(); + source.Scrolled(nativeScrollView); + Assert.False(scrolled); + + return Task.CompletedTask; + + void OnScrolled(object sender, UIScrollView view) + { + scrolled = true; + } + }); + } + [Fact(DisplayName = "Shell Flyout Renderer Disposal Is Idempotent After Native Teardown")] public Task ShellFlyoutRendererDisposalIsIdempotentAfterNativeTeardown() => InvokeOnMainThreadAsync(() => From d54ad274bafd2be9350e15f5199279b56377325f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:16:18 +0200 Subject: [PATCH 25/26] Fix Shell title view handler reparenting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Shell/iOS/ShellFlyoutContentRenderer.cs | 2 +- .../Shell/iOS/ShellFlyoutHeaderContainer.cs | 2 +- .../Handlers/Shell/iOS/UIContainerView.cs | 12 ++++++++-- .../Elements/Shell/ShellTests.iOS.cs | 24 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index 2e632bee426b..7e8c723113a8 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -164,7 +164,7 @@ void UpdateFlyoutFooter(View view) if (_footer is not null) { - _footerView = new UIContainerView(_footer); + _footerView = new UIContainerView(_footer, ownsHandler: true); _uIViews[FooterIndex] = _footerView; AddViewInCorrectOrder(_footerView, previousIndex); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs index a86ca5fd1a01..5db59d2c5e14 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutHeaderContainer.cs @@ -8,7 +8,7 @@ internal class ShellFlyoutHeaderContainer : UIContainerView, IPlatformMeasureInv { Thickness _safearea = Thickness.Zero; - public ShellFlyoutHeaderContainer(View view) : base(view) + public ShellFlyoutHeaderContainer(View view) : base(view, ownsHandler: true) { UpdateSafeAreaMargin(); } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs index 480b559c5960..bf4d9db21eb9 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerView.cs @@ -11,7 +11,8 @@ namespace Microsoft.Maui.Controls.Platform.Compatibility public class UIContainerView : UIView { readonly View _view; - [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Renderer is owned by the container view and disconnected and cleared in Disconnect.")] + readonly bool _ownsHandler; + [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Captured handler reference is cleared in Disconnect; owning flyout containers also disconnect it.")] IPlatformViewHandler _renderer; [UnconditionalSuppressMessage("Memory", "MEM0002", Justification = "Platform view is owned as a UIKit subview and detached and cleared in Disconnect.")] UIView _platformView; @@ -22,8 +23,14 @@ public class UIContainerView : UIView internal event EventHandler HeaderSizeChanged; public UIContainerView(View view) + : this(view, ownsHandler: false) + { + } + + internal UIContainerView(View view, bool ownsHandler) { _view = view; + _ownsHandler = ownsHandler; UpdatePlatformView(); ClipsToBounds = true; @@ -149,7 +156,8 @@ internal void Disconnect() if (platformView?.Superview == this) platformView.RemoveFromSuperview(); - renderer?.DisconnectHandler(); + if (_ownsHandler) + renderer?.DisconnectHandler(); } protected override void Dispose(bool disposing) diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index 9637a68bc027..e5b2e9fd6548 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -687,6 +687,30 @@ await CreateHandlerAndAddToWindow(shell, handler => }); } + [Fact(DisplayName = "Disconnecting Reparented UI Container Preserves Shared Handler")] + public Task DisconnectingReparentedUIContainerPreservesSharedHandler() + { + SetupBuilder(); + + return InvokeOnMainThreadAsync(() => + { + var titleView = new Label { Text = "Title View" }; + var handler = CreateHandler(titleView); + using var firstContainer = new UIContainerView(titleView); + using var secondContainer = new UIContainerView(titleView); + var platformView = handler.PlatformView; + + Assert.Same(secondContainer, platformView.Superview); + + firstContainer.Disconnect(); + + Assert.Same(handler, titleView.Handler); + Assert.Same(secondContainer, platformView.Superview); + + ((IElementHandler)handler).DisconnectHandler(); + }); + } + [Fact(DisplayName = "Disposed Shell Table Source Clears Scrolled Subscribers")] public async Task DisposedShellTableSourceClearsScrolledSubscribers() { From a6a655950d7b7d7c7d281aeb9c0b102b399b7712 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:49:20 +0200 Subject: [PATCH 26/26] Fix Shell teardown review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 926cb3a5-a29d-45bd-8c25-35da659b8dfd --- .../Shell/iOS/ShellFlyoutContentRenderer.cs | 1 + .../Handlers/Shell/iOS/ShellFlyoutRenderer.cs | 10 +++++---- .../Handlers/Shell/iOS/ShellRenderer.cs | 20 ++++++++++++++++-- .../Elements/Shell/ShellTests.iOS.cs | 21 ++++++++++++++++++- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs index 7e8c723113a8..75050ac04b0b 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutContentRenderer.cs @@ -474,6 +474,7 @@ protected override void Dispose(bool disposing) _blurView?.Dispose(); _headerView?.Dispose(); _footerView?.Dispose(); + _tableViewController?.ViewIfLoaded?.RemoveFromSuperview(); _tableViewController?.RemoveFromParentViewController(); _tableViewController?.Dispose(); WillAppear = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs index 6cb0e8ea0424..dee294d09f7f 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutRenderer.cs @@ -272,10 +272,12 @@ protected override void Dispose(bool disposing) if (disposing) { - ShellController.RemoveAppearanceObserver(this); - - Shell.PropertyChanged -= OnShellPropertyChanged; - ((IShellController)Shell).RemoveFlyoutBehaviorObserver(this); + if (Shell is not null) + { + ShellController.RemoveAppearanceObserver(this); + Shell.PropertyChanged -= OnShellPropertyChanged; + ((IShellController)Shell).RemoveFlyoutBehaviorObserver(this); + } _flyoutAnimation?.StopAnimation(true); _flyoutAnimation = null; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs index 8648c48676fa..fb1e2ac18337 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellRenderer.cs @@ -296,12 +296,27 @@ void CancelActiveTransition() _activeTransitionCancellation?.TrySetResult(true); } - static async Task WaitForTransitionOrCancellationAsync(Task transition, Task cancellation) + static async Task WaitForTransitionOrCancellationAsync(Task transition, Task cancellation, ILogger logger) { var completedTask = await Task.WhenAny(transition, cancellation); + if (!ReferenceEquals(completedTask, transition)) + _ = ObserveTransitionAsync(transition, logger); + await completedTask; } + static async Task ObserveTransitionAsync(Task transition, ILogger logger) + { + try + { + await transition.ConfigureAwait(false); + } + catch (Exception exc) when (exc is not OperationCanceledException) + { + logger?.LogWarning(exc, "Shell item transition failed after cancellation"); + } + } + protected virtual async void OnCurrentItemChanged() { try @@ -458,7 +473,8 @@ protected async Task SetCurrentShellItemControllerAsync(IShellItemRenderer value _outgoingRenderer = oldRenderer; _activeTransition = WaitForTransitionOrCancellationAsync( transition.Transition(oldRenderer, newRenderer), - transitionCancellation.Task); + transitionCancellation.Task, + _mauiContext?.CreateLogger()); try { diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs index e5b2e9fd6548..0ac9624e984c 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.iOS.cs @@ -668,11 +668,16 @@ await CreateHandlerAndAddToWindow(shell, handler => .ChildViewControllers .OfType() .First(); + var tableViewController = flyoutContent.ChildViewControllers + .OfType() + .First(); + var tableView = tableViewController.View; var headerPlatformView = header.ToPlatform(); var footerPlatformView = footer.ToPlatform(); Assert.NotNull(header.Handler); Assert.NotNull(footer.Handler); + Assert.NotNull(tableView.Superview); Assert.NotNull(headerPlatformView.Superview); Assert.NotNull(footerPlatformView.Superview); @@ -680,6 +685,7 @@ await CreateHandlerAndAddToWindow(shell, handler => Assert.Null(header.Handler); Assert.Null(footer.Handler); + Assert.Null(tableView.Superview); Assert.Null(headerPlatformView.Superview); Assert.Null(footerPlatformView.Superview); @@ -758,6 +764,16 @@ public Task ShellFlyoutRendererDisposalIsIdempotentAfterNativeTeardown() => renderer.DisposeForTest(true); }); + [Fact(DisplayName = "Unattached Shell Flyout Renderer Can Be Disposed")] + public Task UnattachedShellFlyoutRendererCanBeDisposed() => + InvokeOnMainThreadAsync(() => + { + var renderer = new TestableShellFlyoutRenderer(); + + renderer.DisposeForTest(true); + renderer.DisposeForTest(true); + }); + [Fact(DisplayName = "Disconnect Shell During Current Item Change Does Not Recreate Renderer")] public async Task DisconnectShellDuringCurrentItemChangeDoesNotRecreateRenderer() { @@ -1039,7 +1055,10 @@ public async Task> GetImageAsync( CancellationToken cancellationToken = default) { Starting.Set(); - await Task.Run(() => DoWork.WaitOne()); + // The test needs the canceled load to produce a stale result, but the wait must remain bounded. + if (!await Task.Run(() => DoWork.WaitOne(TimeSpan.FromSeconds(30)))) + throw new TimeoutException("Timed out waiting to continue the delayed image load."); + var image = await Microsoft.Maui.ApplicationModel.MainThread.InvokeOnMainThreadAsync(() => new UIImage()); return new ImageSourceServiceResult(image, () => {