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..63a2158390f4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TabbedPage/iOS/TabbedRenderer.cs @@ -154,6 +154,16 @@ protected override void Dispose(bool disposing) tabbed.PagesChanged -= OnPagesChanged; } + if (_currentBarBackground is GradientBrush currentGradientBrush) + { + if (ReferenceEquals(currentGradientBrush.Parent, Tabbed)) + { + currentGradientBrush.Parent = null; + } + currentGradientBrush.InvalidateGradientBrushRequested -= OnBarBackgroundChanged; + } + _currentBarBackground = null; + FinishedCustomizingViewControllers -= HandleFinishedCustomizingViewControllers; } diff --git a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs index f885d7673f34..8bfb3a66cc7e 100644 --- a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs +++ b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs @@ -129,6 +129,16 @@ public virtual void SetElement(TabbedPage tabbedPage) _viewPager.LayoutChange -= OnLayoutChanged; _viewPager.Adapter = null; + + if (_currentBarBackground is GradientBrush currentGradientBrush) + { + if (ReferenceEquals(currentGradientBrush.Parent, Element)) + { + currentGradientBrush.Parent = null; + } + currentGradientBrush.InvalidateGradientBrushRequested -= OnBarBackgroundChanged; + } + _currentBarBackground = null; } Element = tabbedPage; diff --git a/src/Controls/src/Core/Toolbar/Toolbar.Android.cs b/src/Controls/src/Core/Toolbar/Toolbar.Android.cs index 34946e48b3ac..057e1e35381e 100644 --- a/src/Controls/src/Core/Toolbar/Toolbar.Android.cs +++ b/src/Controls/src/Core/Toolbar/Toolbar.Android.cs @@ -19,7 +19,7 @@ public partial class Toolbar List _currentMenuItems = new List(); List _currentToolbarItems = new List(); - Brush _currentBarBackground; + Brush? _currentBarBackground; private int? _defaultStartInset; NavigationRootManager? NavigationRootManager => @@ -34,6 +34,17 @@ partial void OnHandlerChanging(IElementHandler oldHandler, IElementHandler newHa if (_platformTitleView != null) _platformTitleView.Child = null; + if (_currentBarBackground is GradientBrush currentGradientBrush) + { + if (ReferenceEquals(currentGradientBrush.Parent, this)) + { + currentGradientBrush.Parent = null; + } + + currentGradientBrush.InvalidateGradientBrushRequested -= OnBarBackgroundChanged; + } + _currentBarBackground = null; + Controls.Platform.ToolbarExtensions.DisposeMenuItems( oldHandler?.PlatformView as AToolbar, ToolbarItems, diff --git a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs index f8ddc4ac8cb5..a46e60c6cabd 100644 --- a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -378,7 +379,7 @@ await CreateHandlerAndAddToWindow(new Window(tabbedPage), asy } #endif -#if IOS +#if IOS [Theory(Skip = "Test doesn't work on iOS yet; probably because of https://github.com/dotnet/maui/issues/10591")] #elif WINDOWS [Theory(Skip = "Test doesn't work on Windows")] @@ -503,5 +504,164 @@ TabbedPage CreateBasicTabbedPage(bool bottomTabs = false, bool isSmoothScrollEna Controls.PlatformConfiguration.AndroidSpecific.TabbedPage.SetIsSmoothScrollEnabled(tabs, isSmoothScrollEnabled); return tabs; } + + // https://github.com/dotnet/maui/issues/35469 + // When a TabbedPage.BarBackground is set from a shared GradientBrush (e.g. via an app-resource Style), + // removing the TabbedPage from the window must not leave a live subscription on the shared brush. + // The renderer/manager subscribes to GradientBrush.InvalidateGradientBrushRequested; if it fails to + // unsubscribe on disconnect the brush keeps the renderer alive, preventing GC. + [Fact(DisplayName = "TabbedPage renderer/manager does not leak shared GradientBrush subscriber on disconnect")] + public async Task TabbedPageDoesNotLeakGradientBrushSubscriberOnDisconnect() + { + SetupBuilder(); + + // Simulate a shared app-resource brush — this object lives beyond the TabbedPage. + var sharedBrush = new LinearGradientBrush( + new GradientStopCollection + { + new GradientStop(Colors.Teal, 0f), + new GradientStop(Colors.CornflowerBlue, 1f) + }, + new Graphics.Point(0, 0), + new Graphics.Point(1, 1)); + + var tabbedPageRef = new WeakReference(null); + var handlerRef = new WeakReference(null); + + var navPage = new NavigationPage(new ContentPage { Title = "Home" }); + + await CreateHandlerAndAddToWindow(new Window(navPage), async () => + { + var tabbedPage = new TabbedPage + { + Title = "Tabbed", + BarBackground = sharedBrush, + }; + tabbedPage.Children.Add(new ContentPage { Title = "Tab 1" }); + tabbedPage.Children.Add(new ContentPage { Title = "Tab 2" }); + + // Push so the TabbedPage handler is created and subscribes to the brush. + await navPage.Navigation.PushModalAsync(tabbedPage); + await OnLoadedAsync(tabbedPage.Children[0]); + + tabbedPageRef.Target = tabbedPage; + handlerRef.Target = tabbedPage.Handler; + + // Pop — this should cause the renderer/manager to disconnect and unsubscribe. + await navPage.Navigation.PopModalAsync(); + }); + + // After full GC the renderer/manager and the page itself should be collected. + await AssertionExtensions.WaitForGC(handlerRef); + await AssertionExtensions.WaitForGC(tabbedPageRef); + + // The shared brush must have zero subscribers to InvalidateGradientBrushRequested. + // If the renderer/manager leaked, it would still be subscribed here. + var brushInvocationList = GetGradientBrushInvocationList(sharedBrush); + Assert.Empty(brushInvocationList); + } + + // https://github.com/dotnet/maui/issues/35469 + // Variant: BarBackground applied via a Style (the exact scenario from the bug report). + [Fact(DisplayName = "TabbedPage renderer/manager does not leak shared GradientBrush subscriber when BarBackground is set via Style")] + public async Task TabbedPageDoesNotLeakGradientBrushSubscriberWhenSetViaStyle() + { + SetupBuilder(); + + var sharedBrush = new LinearGradientBrush( + new GradientStopCollection + { + new GradientStop(Colors.DarkGreen, 0f), + new GradientStop(Colors.SteelBlue, 1f) + }, + new Graphics.Point(0, 0), + new Graphics.Point(1, 1)); + + var barBackgroundStyle = new Style(typeof(TabbedPage)) + { + Setters = { new Setter { Property = TabbedPage.BarBackgroundProperty, Value = sharedBrush } } + }; + + var handlerRef = new WeakReference(null); + + var navPage = new NavigationPage(new ContentPage { Title = "Home" }); + + await CreateHandlerAndAddToWindow(new Window(navPage), async () => + { + var tabbedPage = new TabbedPage { Style = barBackgroundStyle }; + tabbedPage.Children.Add(new ContentPage { Title = "Tab 1" }); + tabbedPage.Children.Add(new ContentPage { Title = "Tab 2" }); + + await navPage.Navigation.PushModalAsync(tabbedPage); + await OnLoadedAsync(tabbedPage.Children[0]); + + handlerRef.Target = tabbedPage.Handler; + + await navPage.Navigation.PopModalAsync(); + }); + + await AssertionExtensions.WaitForGC(handlerRef); + + var brushInvocationList = GetGradientBrushInvocationList(sharedBrush); + Assert.Empty(brushInvocationList); + } + + + // https://github.com/dotnet/maui/issues/35469 + // Verifies that after a modal TabbedPage is popped, the shared GradientBrush + // has zero subscribers. DisconnectHandlers is NOT called on modal pop, so the + // fix must explicitly unsubscribe via the Disappearing/ViewDidDisappear lifecycle hook. + [Fact(DisplayName = "TabbedPage GradientBrush subscriber is removed after modal pop")] + public async Task TabbedPageGradientBrushSubscriberRemovedAfterModalPop() + { + SetupBuilder(); + + var sharedBrush = new LinearGradientBrush( + new GradientStopCollection + { + new GradientStop(Colors.Purple, 0f), + new GradientStop(Colors.Orange, 1f) + }, + new Graphics.Point(0, 0), + new Graphics.Point(1, 1)); + + var navPage = new NavigationPage(new ContentPage { Title = "Home" }); + + await CreateHandlerAndAddToWindow(new Window(navPage), async () => + { + var tabbedPage = new TabbedPage + { + Title = "Tabbed", + BarBackground = sharedBrush, + }; + tabbedPage.Children.Add(new ContentPage { Title = "Tab 1" }); + tabbedPage.Children.Add(new ContentPage { Title = "Tab 2" }); + + await navPage.Navigation.PushModalAsync(tabbedPage); + await OnLoadedAsync(tabbedPage.Children[0]); + + // Confirm the brush has exactly one subscriber while the page is live. + Assert.Single(GetGradientBrushInvocationList(sharedBrush)); + + await navPage.Navigation.PopModalAsync(); + + // After pop completes, Disappearing/ViewDidDisappear must have fired + // and explicitly unsubscribed from the brush. + var invocationList = GetGradientBrushInvocationList(sharedBrush); + Assert.Empty(invocationList); + }); + } + + static System.Delegate[] GetGradientBrushInvocationList(GradientBrush brush) + { + var field = typeof(GradientBrush).GetField( + "InvalidateGradientBrushRequested", + BindingFlags.Instance | BindingFlags.NonPublic); + + if (field is null) + return []; + + return (field.GetValue(brush) as MulticastDelegate)?.GetInvocationList() ?? []; + } } }