Skip to content

Commit 1087eb3

Browse files
Fix MauiView leaks detached platform views when SafeAreaEdges includes SoftInput (#35586)
<!-- Please let the below note in for people that find this PR --> > [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Details: Every MauiView that uses SafeAreaEdges.SoftInput stays alive in memory after being removed from the screen. Users saw platform=12/12 views never getting released in iOS and Mac platform. ### Root Cause: When a MAUI view is configured with SafeAreaEdges.SoftInput on iOS or MacCatalyst, MauiView.SubscribeToKeyboardNotifications() registers two observers with NSNotificationCenter.DefaultCenter — one for UIKeyboard.WillShowNotification and one for UIKeyboard.WillHideNotification. Each call to AddObserver returns an NSObject token that the notification center holds strongly. Because the callbacks (OnKeyboardWillShow, OnKeyboardWillHide) are instance methods, the delegate objects capture a strong reference to this — the MauiView instance. This creates an unbroken retain chain: NSNotificationCenter → NSObject token → delegate → MauiView. As long as those observer tokens are registered, the notification center transitively keeps every subscribed MauiView alive, regardless of whether the view has been removed from the visual tree, its handler disconnected, and all MAUI-side references dropped. The reason the tokens were never removed on detach comes down to a single guard condition in UpdateKeyboardSubscription(). The method was structured as if (Window != null) { ... }, meaning all subscription management — both subscribe and unsubscribe — was gated on the view being attached to a window. When MovedToWindow() fires with Window == null (the standard UIKit signal that a view has been removed from the hierarchy), UpdateKeyboardSubscription() was called but immediately fell through without doing anything. UnsubscribeFromKeyboardNotifications() was never reached, leaving the observer tokens live in the notification center indefinitely. The result, confirmed by the sandbox repro, was platform=12/12 alive after 12 forced GC cycles for views using SafeAreaEdges.SoftInput, versus platform=0/12 for the control case. ### Description of Change: The fix flattens the conditional in UpdateKeyboardSubscription() so that the else branch — which calls UnsubscribeFromKeyboardNotifications() — fires for all cases where the view should not be actively subscribed, including detach. The original nested structure if (Window != null) { if (ShouldSubscribe) subscribe; else unsubscribe; } is replaced with if (Window != null && ShouldSubscribeToKeyboardNotifications()) { SubscribeToKeyboardNotifications(); } else { UnsubscribeFromKeyboardNotifications(); }. Now when MovedToWindow() fires with Window == null, the condition evaluates to false and UnsubscribeFromKeyboardNotifications() is called, issuing NSNotificationCenter.DefaultCenter.RemoveObserver(token) for both keyboard observers. This severs the retain chain and allows the MauiView to be collected normally by the GC. The change is 9 lines, touches a single method, and introduces no new state or lifecycle hooks. **Tested the behavior in the following platforms:** - [x] Android - [ ] Windows - [x] iOS - [ ] Mac ### Reference: N/A ### Issues Fixed: Fixes #35386 ### Screenshots | Before | After | |---------|--------| | <img src="https://github.com/user-attachments/assets/cbbd452a-6565-4e0b-a17d-12c83204bfe5" Width="400" Height="600"/> | <img src="https://github.com/user-attachments/assets/3ebbeba2-9fd1-42ac-a4d5-06d5184f2cc7" Width="400" Height="600"/> | ---------
1 parent 84345dc commit 1087eb3

4 files changed

Lines changed: 290 additions & 15 deletions

File tree

eng/pipelines/ci-copilot.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ stages:
124124
skipAndroidPlatformApis: true
125125
onlyAndroidPlatformDefaultApis: true
126126
skipAndroidEmulatorImages: ${{ ne(parameters.Platform, 'android') }}
127-
skipAndroidCreateAvds: ${{ ne(parameters.Platform, 'android') }}
127+
# AVD is created by the inline 'Create AVD and boot Android Emulator' script below
128+
# with specific config (playstore image variant, partition shrink, ADB key pre-auth)
129+
# that ProvisionAndroidSdkAvdCreateAvds doesn't replicate.
130+
skipAndroidCreateAvds: true
128131
androidEmulatorApiLevel: '30'
129132
skipSimulatorSetup: ${{ or(eq(parameters.Platform, 'android'), eq(parameters.Platform, 'windows'), eq(parameters.Platform, 'catalyst')) }}
130133
skipCertificates: true
@@ -871,7 +874,10 @@ stages:
871874
skipAndroidPlatformApis: true
872875
onlyAndroidPlatformDefaultApis: true
873876
skipAndroidEmulatorImages: ${{ ne(parameters.Platform, 'android') }}
874-
skipAndroidCreateAvds: ${{ ne(parameters.Platform, 'android') }}
877+
# AVD is created by the inline 'Create AVD and boot Android Emulator' script below
878+
# with specific config (playstore image variant, partition shrink, ADB key pre-auth)
879+
# that ProvisionAndroidSdkAvdCreateAvds doesn't replicate.
880+
skipAndroidCreateAvds: true
875881
androidEmulatorApiLevel: '30'
876882
skipSimulatorSetup: ${{ or(eq(parameters.Platform, 'android'), eq(parameters.Platform, 'windows'), eq(parameters.Platform, 'catalyst')) }}
877883
skipCertificates: true
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
8+
namespace Maui.Controls.Sample.Issues;
9+
10+
[Issue(IssueTracker.Github, 35386, "MauiView leaks detached platform views when SafeAreaEdges includes SoftInput", PlatformAffected.iOS)]
11+
public class Issue35386 : ContentPage
12+
{
13+
const int CycleCount = 12;
14+
15+
readonly Grid _host;
16+
readonly Label _status;
17+
readonly VerticalStackLayout _log;
18+
bool _started;
19+
20+
public Issue35386()
21+
{
22+
Title = "SoftInput observer leak repro";
23+
BackgroundColor = Colors.White;
24+
25+
_status = new Label
26+
{
27+
Text = "Waiting to start...",
28+
TextColor = Colors.Black,
29+
FontSize = 16,
30+
AutomationId = "statusLabel",
31+
LineBreakMode = LineBreakMode.WordWrap
32+
};
33+
34+
_log = new VerticalStackLayout
35+
{
36+
Spacing = 4
37+
};
38+
39+
_host = new Grid
40+
{
41+
BackgroundColor = Color.FromArgb("#f3f6fb"),
42+
HeightRequest = 160
43+
};
44+
45+
var scrollView = new ScrollView();
46+
var verticalStackLayout = new VerticalStackLayout();
47+
48+
var label = new Label
49+
{
50+
Text = "MAUI SoftInput Observer Leak Repro",
51+
TextColor = Colors.Black,
52+
FontSize = 22,
53+
FontAttributes = FontAttributes.Bold
54+
};
55+
verticalStackLayout.Children.Add(label);
56+
verticalStackLayout.Children.Add(_status);
57+
verticalStackLayout.Children.Add(_host);
58+
verticalStackLayout.Children.Add(_log);
59+
60+
scrollView.Content = verticalStackLayout;
61+
Content = scrollView;
62+
63+
}
64+
65+
protected override async void OnAppearing()
66+
{
67+
base.OnAppearing();
68+
69+
if (_started)
70+
return;
71+
72+
_started = true;
73+
await Task.Delay(500);
74+
75+
try
76+
{
77+
await RunAsync();
78+
}
79+
catch (Exception ex)
80+
{
81+
Log("ERROR: " + ex);
82+
_status.Text = "Repro failed: " + ex.Message;
83+
//await ExitAsync(3);
84+
}
85+
}
86+
87+
async Task RunAsync()
88+
{
89+
Log("Running control scenario: SafeAreaEdges.None");
90+
var none = await RunScenarioAsync("none", SafeAreaEdges.None);
91+
92+
Log("Running suspect scenario: SafeAreaEdges.SoftInput");
93+
var softInput = await RunScenarioAsync("softinput", new SafeAreaEdges(SafeAreaRegions.SoftInput));
94+
95+
var proof = softInput.PlatformAlive > 0 && none.PlatformAlive == 0;
96+
var summary =
97+
$"RESULT: {(proof ? "LEAK REPRODUCED" : "NOT PROVEN")}\n" +
98+
$"Control SafeAreaEdges.None: virtual={none.VirtualAlive}/{CycleCount}, handler={none.HandlerAlive}/{CycleCount}, platform={none.PlatformAlive}/{CycleCount}\n" +
99+
$"Suspect SafeAreaEdges.SoftInput: virtual={softInput.VirtualAlive}/{CycleCount}, handler={softInput.HandlerAlive}/{CycleCount}, platform={softInput.PlatformAlive}/{CycleCount}\n";
100+
101+
_status.Text = summary;
102+
Log(summary);
103+
104+
//await ExitAsync(proof ? 0 : 2);
105+
}
106+
107+
async Task<ScenarioResult> RunScenarioAsync(string name, SafeAreaEdges safeAreaEdges)
108+
{
109+
var probes = new List<ProbeRefs>();
110+
111+
for (var i = 0; i < CycleCount; i++)
112+
{
113+
probes.Add(await CreateAndRemoveProbeAsync(name, safeAreaEdges, i));
114+
await ForceGcAsync();
115+
Log($"{name} cycle {i + 1}: platform alive={probes.Count(p => p.PlatformView.IsAlive)}");
116+
}
117+
118+
await Task.Delay(500);
119+
await ForceGcAsync();
120+
await ForceGcAsync();
121+
122+
return new ScenarioResult(
123+
name,
124+
probes.Count(p => p.VirtualView.IsAlive),
125+
probes.Count(p => p.Handler.IsAlive),
126+
probes.Count(p => p.PlatformView.IsAlive));
127+
}
128+
129+
async Task<ProbeRefs> CreateAndRemoveProbeAsync(string scenarioName, SafeAreaEdges safeAreaEdges, int index)
130+
{
131+
WeakReference? virtualView = null;
132+
WeakReference? handler = null;
133+
WeakReference? platformView = null;
134+
135+
await MainThread.InvokeOnMainThreadAsync(async () =>
136+
{
137+
var probe = new Grid
138+
{
139+
SafeAreaEdges = safeAreaEdges,
140+
AutomationId = $"{scenarioName}-probe-{index}",
141+
HeightRequest = 96,
142+
BackgroundColor = scenarioName == "softinput" ? Color.FromArgb("#ffe8e8") : Color.FromArgb("#e8f1ff"),
143+
RowDefinitions =
144+
{
145+
new RowDefinition(GridLength.Auto),
146+
new RowDefinition(GridLength.Star)
147+
}
148+
};
149+
150+
probe.Add(new Label
151+
{
152+
Text = $"{scenarioName} #{index}",
153+
TextColor = Colors.Black,
154+
Margin = new Thickness(8, 6, 8, 0)
155+
});
156+
157+
probe.Add(new Entry
158+
{
159+
Text = "entry",
160+
Margin = new Thickness(8),
161+
AutomationId = $"{scenarioName}-entry-{index}"
162+
}, row: 1);
163+
164+
_host.Children.Add(probe);
165+
await WaitUntilLoadedAsync(probe);
166+
await Task.Delay(100);
167+
168+
var currentHandler = probe.Handler;
169+
var currentPlatformView = currentHandler?.PlatformView;
170+
171+
if (currentHandler is null || currentPlatformView is null)
172+
throw new InvalidOperationException($"Probe {scenarioName} #{index} did not create a handler/platform view.");
173+
174+
virtualView = new WeakReference(probe);
175+
handler = new WeakReference(currentHandler);
176+
platformView = new WeakReference(currentPlatformView);
177+
178+
_host.Children.Remove(probe);
179+
probe.DisconnectHandlers();
180+
181+
probe = null!;
182+
currentHandler = null;
183+
currentPlatformView = null;
184+
});
185+
186+
await Task.Delay(250);
187+
await ForceGcAsync();
188+
189+
return new ProbeRefs(
190+
virtualView ?? throw new InvalidOperationException("Missing virtual view reference."),
191+
handler ?? throw new InvalidOperationException("Missing handler reference."),
192+
platformView ?? throw new InvalidOperationException("Missing platform view reference."));
193+
}
194+
195+
static async Task WaitUntilLoadedAsync(VisualElement element)
196+
{
197+
if (element.IsLoaded)
198+
return;
199+
200+
var loaded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
201+
202+
void OnLoaded(object? sender, EventArgs args)
203+
{
204+
element.Loaded -= OnLoaded;
205+
loaded.TrySetResult();
206+
}
207+
208+
element.Loaded += OnLoaded;
209+
210+
var completed = await Task.WhenAny(loaded.Task, Task.Delay(TimeSpan.FromSeconds(3)));
211+
element.Loaded -= OnLoaded;
212+
213+
if (completed != loaded.Task)
214+
throw new TimeoutException("Probe view did not load.");
215+
}
216+
217+
static async Task ForceGcAsync()
218+
{
219+
for (var i = 0; i < 4; i++)
220+
{
221+
GC.Collect();
222+
GC.WaitForPendingFinalizers();
223+
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
224+
await Task.Delay(50);
225+
}
226+
}
227+
228+
void Log(string message)
229+
{
230+
_log.Children.Add(new Label
231+
{
232+
Text = message,
233+
TextColor = Colors.Black,
234+
FontSize = 12,
235+
LineBreakMode = LineBreakMode.WordWrap
236+
});
237+
}
238+
239+
readonly record struct ProbeRefs(WeakReference VirtualView, WeakReference Handler, WeakReference PlatformView);
240+
readonly record struct ScenarioResult(string Name, int VirtualAlive, int HandlerAlive, int PlatformAlive);
241+
}
242+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#if IOS || ANDROID // SoftAreaEdges is only available on mobile platforms
2+
using NUnit.Framework;
3+
using UITest.Appium;
4+
using UITest.Core;
5+
6+
namespace Microsoft.Maui.TestCases.Tests.Issues;
7+
8+
public class Issue35386 : _IssuesUITest
9+
{
10+
public Issue35386(TestDevice device) : base(device) { }
11+
12+
public override string Issue => "MauiView leaks detached platform views when SafeAreaEdges includes SoftInput";
13+
14+
[Test]
15+
[Category(UITestCategories.SafeAreaEdges)]
16+
public void SoftInputSafeArea_DetachedPlatformViews_DoNotLeak()
17+
{
18+
App.WaitForElement("statusLabel");
19+
Assert.That(
20+
App.WaitForTextToBePresentInElement("statusLabel", "Suspect SafeAreaEdges.SoftInput: virtual=0/12, handler=0/12, platform=0/12", timeout: TimeSpan.FromSeconds(60)),
21+
Is.True,
22+
"The status label did not reach the expected SoftInput summary text.");
23+
}
24+
}
25+
#endif

src/Core/src/Platform/iOS/MauiView.cs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -340,27 +340,29 @@ void UnsubscribeFromKeyboardNotifications()
340340
_keyboardWillHideObserver = null;
341341
}
342342

343-
// Clear stale keyboard state so that re-subscribing later doesn't
344-
// pick up a phantom keyboard frame from a previous session (#34846).
343+
// If the keyboard was visible when we unsubscribed (e.g. view detached while keyboard
344+
// is up), we will never receive the WillHide notification. Clear the stale state now
345+
// so that safe-area calculations are correct if the view is later re-attached.
345346
if (_isKeyboardShowing)
346347
{
347-
ClearKeyboardState();
348+
_keyboardFrame = CGRect.Empty;
349+
_isKeyboardShowing = false;
350+
_safeAreaInvalidated = true;
348351
}
349352
}
350353

351354
void UpdateKeyboardSubscription()
352355
{
353-
// Update keyboard subscription based on current SafeAreaEdges settings
354-
if (Window != null)
356+
// Subscribe only when attached to a window and SoftInput edges are configured.
357+
// Always unsubscribe when detached (Window == null) to release the NSNotificationCenter
358+
// observer tokens that otherwise retain this MauiView instance and cause memory leaks.
359+
if (Window != null && ShouldSubscribeToKeyboardNotifications())
355360
{
356-
if (ShouldSubscribeToKeyboardNotifications())
357-
{
358-
SubscribeToKeyboardNotifications();
359-
}
360-
else
361-
{
362-
UnsubscribeFromKeyboardNotifications();
363-
}
361+
SubscribeToKeyboardNotifications();
362+
}
363+
else
364+
{
365+
UnsubscribeFromKeyboardNotifications();
364366
}
365367
}
366368

0 commit comments

Comments
 (0)