[Android] SafeArea: Skip RequestApplyInsets for completely off-screen views#33747
[Android] SafeArea: Skip RequestApplyInsets for completely off-screen views#33747
Conversation
Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com>
- Add HashSet tracking in SafeAreaExtensions.cs to prevent infinite RequestApplyInsets loop when views are positioned off-screen - Inactive TabbedPage tabs are intentionally at x=screenWidth, which was triggering viewExtendsBeyondScreen check continuously - Add UI test that monitors GC.CollectionCount(0) to detect the bug - Add Sandbox app reproduction for manual testing Fixes #33731
- Add mandatory Output Structure section with PowerShell setup commands - Add explicit file creation instructions in Step 4 (approach.md) - Add detailed artifact capture commands in Step 7 with verification - Ensures agents create required files (approach.md, result.txt, fix.diff, analysis.md) for ai-summary-comment compatibility
- Add explicit table showing what counts as Pass vs Fail - Clarify that compilation alone is NOT a Pass - Add 'Blocked' as valid result when device unavailable - Prevent agents from manufacturing success without real tests
Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com>
Cherry-picked from PR #33747. When using -DryRun, the script now properly: - Extracts existing TRY-FIX content to preserve previous attempts - Checks if the current attempt already exists (replace vs append) - Maintains the same logic as the GitHub comment update path
AI SummaryTry-Fix Analysis[PASS] Fix 1## Approach: Parent Layout Check + Cached RunnableThe current fix in PR creates infinite lambda allocations because Root Cause: The lambda My Fix:
Different from existing fix: Current fix just posts lambda directly. This approach adds a layout state check AND eliminates allocations by using a cached runnable instead of lambdas. Implementation:
diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..4b9275373d 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -3,9 +3,31 @@ using Android.Content;
using Android.Views;
using AndroidX.Core.View;
using Google.Android.Material.AppBar;
+using Java.Lang;
namespace Microsoft.Maui.Platform;
+// Cached runnable to avoid lambda allocations in RequestApplyInsets
+internal sealed class RequestInsetsRunnable : Java.Lang.Object, IRunnable
+{
+ private static readonly RequestInsetsRunnable _instance = new();
+ private WeakReference? _viewRef;
+
+ public static void PostFor(View view)
+ {
+ _instance._viewRef = new WeakReference(view);
+ view.Post(_instance);
+ }
+
+ public void Run()
+ {
+ if (_viewRef?.Target is View view)
+ {
+ ViewCompat.RequestApplyInsets(view);
+ }
+ }
+}
+
internal static class SafeAreaExtensions
{
internal static ISafeAreaView2? GetSafeAreaView2(object? layout) =>
@@ -127,8 +149,8 @@ internal static class SafeAreaExtensions
// If the Width and Height aren't set it means the layout pass hasn't happen yet
if (view.Width > 0 && view.Height > 0)
{
- viewTop = Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
- viewLeft = Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
+ viewTop = System.Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
+ viewLeft = System.Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
viewRight += (int)context.ToPixels(margins.Right);
viewBottom += (int)context.ToPixels(margins.Bottom);
}
@@ -151,11 +173,14 @@ internal static class SafeAreaExtensions
if (viewExtendsBeyondScreen)
{
- // Request insets to be reapplied after the next layout pass
- // when the view should be properly positioned.
- // Don't return early - let processing continue with current insets
- // to avoid visual popping, the re-apply will correct any issues.
- view.Post(() => ViewCompat.RequestApplyInsets(view));
+ // Only request re-apply if parent is still in layout phase
+ // This prevents infinite loops for permanently off-screen views
+ if (view.Parent?.IsLayoutRequested == true)
+ {
+ // Request insets to be reapplied after the next layout pass
+ // Use cached runnable to avoid lambda allocations
+ RequestInsetsRunnable.PostFor(view);
+ }
}
// Calculate actual overlap for each edge
@@ -165,7 +190,7 @@ internal static class SafeAreaExtensions
if (top > 0 && viewTop < top && viewTop >= 0)
{
// Calculate the actual overlap amount
- top = Math.Min(top - viewTop, top);
+ top = System.Math.Min(top - viewTop, top);
}
else
{
@@ -178,7 +203,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var bottomEdge = screenHeight - bottom;
- bottom = Math.Min(viewBottom - bottomEdge, bottom);
+ bottom = System.Math.Min(viewBottom - bottomEdge, bottom);
}
else
{
@@ -192,7 +217,7 @@ internal static class SafeAreaExtensions
if (left > 0 && viewLeft < left)
{
// Calculate the actual overlap amount
- left = Math.Min(left - viewLeft, left);
+ left = System.Math.Min(left - viewLeft, left);
}
else
{
@@ -205,7 +230,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var rightEdge = screenWidth - right;
- right = Math.Min(viewRight - rightEdge, right);
+ right = System.Math.Min(viewRight - rightEdge, right);
}
else
{
AnalysisResult: Pass What happened: The test executed successfully and passed. The TabbedPageShouldNotCauseExcessiveGC test detected fewer than 2 GC events during the 10-second monitoring period, indicating that the infinite lambda allocation issue has been resolved. Why it worked:
Root cause analysis: The original PR #33285 created an infinite loop where views positioned off-screen (like inactive TabbedPage tabs at x=screenWidth) would continuously trigger Insights:
[PASS] Fix 2## Approach: Geometric Off-Screen CheckSkip RequestApplyInsets for views completely off-screen using a simple bounds check. Problem: Line 158 calls \�iew.Post(() => ViewCompat.RequestApplyInsets(view))\ continuously for inactive TabbedPage tabs positioned off-screen, creating infinite lambda allocations and GC pressure. Solution: Before requesting insets re-apply, check if the view is completely off-screen using a geometric bounds check: If the view is completely off-screen (no intersection with visible area), skip the RequestApplyInsets call entirely. Different from existing fix: This approach uses pure geometric logic at the point where RequestApplyInsets is called, preventing the problematic Posts from being queued. Instead of tracking state or caching, it makes a simple geometric determination before posting. Location: Line 152-159 in SafeAreaExtensions.cs - add the check inside the \if (viewExtendsBeyondScreen)\ block diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..e4be8a76c2 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -151,11 +151,20 @@ internal static class SafeAreaExtensions
if (viewExtendsBeyondScreen)
{
- // Request insets to be reapplied after the next layout pass
- // when the view should be properly positioned.
- // Don't return early - let processing continue with current insets
- // to avoid visual popping, the re-apply will correct any issues.
- view.Post(() => ViewCompat.RequestApplyInsets(view));
+ // Check if view is completely off-screen (no intersection with visible area).
+ // If completely off-screen (e.g., TabbedPage tabs), skip RequestApplyInsets
+ // to avoid infinite lambda allocations and GC pressure.
+ bool isCompletelyOffScreen = viewLeft >= screenWidth || viewRight <= 0 ||
+ viewTop >= screenHeight || viewBottom <= 0;
+
+ if (!isCompletelyOffScreen)
+ {
+ // Request insets to be reapplied after the next layout pass
+ // when the view should be properly positioned.
+ // Don't return early - let processing continue with current insets
+ // to avoid visual popping, the re-apply will correct any issues.
+ view.Post(() => ViewCompat.RequestApplyInsets(view));
+ }
}
// Calculate actual overlap for each edge
AnalysisResult: Pass What happened: Why it worked: The approach:
Insights:
[PASS] Fix 3## Approach: ConditionalWeakTable Debounced InsetsUse a ConditionalWeakTable<View, DebouncedInsetsRequestState> to track per-view pending inset requests and Different from existing fix: Prior PR directly called view.Post(RequestApplyInsets) whenever the view diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..de3c4789ad 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -1,4 +1,5 @@
using System;
+using System.Runtime.CompilerServices;
using Android.Content;
using Android.Views;
using AndroidX.Core.View;
@@ -8,6 +9,15 @@ namespace Microsoft.Maui.Platform;
internal static class SafeAreaExtensions
{
+ // Tracks per-view inset requests to avoid repeated allocations when a view
+ // remains off-screen (for example, inactive TabbedPage tabs).
+ static readonly ConditionalWeakTable<View, DebouncedInsetsRequestState> s_insetRequestDebounce = new();
+
+ sealed class DebouncedInsetsRequestState
+ {
+ public bool Pending;
+ }
+
internal static ISafeAreaView2? GetSafeAreaView2(object? layout) =>
layout switch
{
@@ -127,8 +137,8 @@ internal static class SafeAreaExtensions
// If the Width and Height aren't set it means the layout pass hasn't happen yet
if (view.Width > 0 && view.Height > 0)
{
- viewTop = Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
- viewLeft = Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
+ viewTop = System.Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
+ viewLeft = System.Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
viewRight += (int)context.ToPixels(margins.Right);
viewBottom += (int)context.ToPixels(margins.Bottom);
}
@@ -151,11 +161,10 @@ internal static class SafeAreaExtensions
if (viewExtendsBeyondScreen)
{
- // Request insets to be reapplied after the next layout pass
- // when the view should be properly positioned.
- // Don't return early - let processing continue with current insets
- // to avoid visual popping, the re-apply will correct any issues.
- view.Post(() => ViewCompat.RequestApplyInsets(view));
+ // Request insets to be reapplied after the next layout pass,
+ // but debounce per view to avoid repeated allocations when the view
+ // remains off-screen (for example, inactive TabbedPage tabs).
+ RequestApplyInsetsWithDebounce(view);
}
// Calculate actual overlap for each edge
@@ -165,7 +174,7 @@ internal static class SafeAreaExtensions
if (top > 0 && viewTop < top && viewTop >= 0)
{
// Calculate the actual overlap amount
- top = Math.Min(top - viewTop, top);
+ top = System.Math.Min(top - viewTop, top);
}
else
{
@@ -178,7 +187,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var bottomEdge = screenHeight - bottom;
- bottom = Math.Min(viewBottom - bottomEdge, bottom);
+ bottom = System.Math.Min(viewBottom - bottomEdge, bottom);
}
else
{
@@ -192,7 +201,7 @@ internal static class SafeAreaExtensions
if (left > 0 && viewLeft < left)
{
// Calculate the actual overlap amount
- left = Math.Min(left - viewLeft, left);
+ left = System.Math.Min(left - viewLeft, left);
}
else
{
@@ -205,7 +214,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var rightEdge = screenWidth - right;
- right = Math.Min(viewRight - rightEdge, right);
+ right = System.Math.Min(viewRight - rightEdge, right);
}
else
{
@@ -277,6 +286,23 @@ internal static class SafeAreaExtensions
return newWindowInsets;
}
+ static void RequestApplyInsetsWithDebounce(View view)
+ {
+ var state = s_insetRequestDebounce.GetOrCreateValue(view);
+ if (state.Pending)
+ return;
+
+ state.Pending = true;
+
+ view.PostDelayed(new global::Java.Lang.Runnable(() =>
+ {
+ state.Pending = false;
+
+ if (view.IsAttachedToWindow)
+ ViewCompat.RequestApplyInsets(view);
+ }), 250);
+ }
+
internal static double GetSafeAreaForEdge(SafeAreaRegions safeAreaRegion, double originalSafeArea, int edge, bool isKeyboardShowing, SafeAreaPadding keyBoardInsets)
{
// Edge-to-edge content - no safe area padding
AnalysisResult: Pass What happened: The android UI test Issue33731 (TabbedPageShouldNotCauseExcessiveGC) built, deployed, and ran successfully Why it worked: By introducing a per-view debounced RequestApplyInsets using ConditionalWeakTable-backed state and Insights: Safe area handling for Android TabbedPage transitions is sensitive to views temporarily extending beyond screen [PASS] Fix 4# Approach: ViewPager Detection + Position CacheProblem AnalysisThe issue is in
Root CauseThe code at lines 149-159 (original) checks if a view extends beyond screen bounds and unconditionally requests inset re-application. For TabbedPage:
Solution: Two-Part Fix1. ViewPager Detection
2. Position Cache
Implementation DetailsAdded:
Modified:
Build Fix:
Expected Outcome
diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..97082cd688 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -1,13 +1,17 @@
-using System;
+using System.Collections.Generic;
using Android.Content;
using Android.Views;
using AndroidX.Core.View;
+using AndroidX.ViewPager.Widget;
using Google.Android.Material.AppBar;
namespace Microsoft.Maui.Platform;
internal static class SafeAreaExtensions
{
+ // Cache to track view positions and avoid unnecessary inset requests
+ private static readonly Dictionary<int, (int left, int top, int right, int bottom)> _viewPositionCache = new();
+ private const int PositionChangeThreshold = 5; // pixels
internal static ISafeAreaView2? GetSafeAreaView2(object? layout) =>
layout switch
{
@@ -24,6 +28,76 @@ internal static class SafeAreaExtensions
_ => null
};
+ // Helper method to check if view is in an inactive ViewPager page
+ private static bool IsInInactiveViewPagerPage(View view)
+ {
+ View? current = view;
+ ViewPager? viewPager = null;
+
+ // Walk up the view hierarchy to find a ViewPager parent
+ while (current != null)
+ {
+ if (current.Parent is ViewPager vp)
+ {
+ viewPager = vp;
+ break;
+ }
+ current = current.Parent as View;
+ }
+
+ // If we found a ViewPager, check if this view's page is the current page
+ if (viewPager != null)
+ {
+ // Get the view's page index by walking down from ViewPager
+ View? checkView = view;
+ while (checkView != null && checkView.Parent != viewPager)
+ {
+ checkView = checkView.Parent as View;
+ }
+
+ if (checkView != null)
+ {
+ // Check if this is the current ViewPager page
+ for (int i = 0; i < viewPager.ChildCount; i++)
+ {
+ if (viewPager.GetChildAt(i) == checkView)
+ {
+ // This view's page index is i, compare with current item
+ return i != viewPager.CurrentItem;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ // Helper method to check if view position has changed meaningfully
+ private static bool HasPositionChangedSignificantly(View view, int left, int top, int right, int bottom)
+ {
+ int viewId = view.GetHashCode();
+
+ if (_viewPositionCache.TryGetValue(viewId, out var cachedPosition))
+ {
+ // Check if any coordinate changed by more than threshold
+ bool changed = System.Math.Abs(cachedPosition.left - left) > PositionChangeThreshold ||
+ System.Math.Abs(cachedPosition.top - top) > PositionChangeThreshold ||
+ System.Math.Abs(cachedPosition.right - right) > PositionChangeThreshold ||
+ System.Math.Abs(cachedPosition.bottom - bottom) > PositionChangeThreshold;
+
+ if (changed)
+ {
+ _viewPositionCache[viewId] = (left, top, right, bottom);
+ }
+
+ return changed;
+ }
+
+ // First time seeing this view, cache it
+ _viewPositionCache[viewId] = (left, top, right, bottom);
+ return true; // First time is always considered significant
+ }
+
internal static SafeAreaRegions GetSafeAreaRegionForEdge(int edge, ICrossPlatformLayout crossPlatformLayout)
{
@@ -127,8 +201,8 @@ internal static class SafeAreaExtensions
// If the Width and Height aren't set it means the layout pass hasn't happen yet
if (view.Width > 0 && view.Height > 0)
{
- viewTop = Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
- viewLeft = Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
+ viewTop = System.Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
+ viewLeft = System.Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
viewRight += (int)context.ToPixels(margins.Right);
viewBottom += (int)context.ToPixels(margins.Bottom);
}
@@ -151,11 +225,21 @@ internal static class SafeAreaExtensions
if (viewExtendsBeyondScreen)
{
- // Request insets to be reapplied after the next layout pass
- // when the view should be properly positioned.
- // Don't return early - let processing continue with current insets
- // to avoid visual popping, the re-apply will correct any issues.
- view.Post(() => ViewCompat.RequestApplyInsets(view));
+ // Skip inactive ViewPager pages (e.g., off-screen TabbedPage tabs)
+ // to avoid infinite lambda allocations
+ bool isInactiveTab = IsInInactiveViewPagerPage(view);
+
+ // Only request insets if:
+ // 1. View is not in an inactive ViewPager page, AND
+ // 2. Position has changed significantly since last check
+ if (!isInactiveTab && HasPositionChangedSignificantly(view, viewLeft, viewTop, viewRight, viewBottom))
+ {
+ // Request insets to be reapplied after the next layout pass
+ // when the view should be properly positioned.
+ // Don't return early - let processing continue with current insets
+ // to avoid visual popping, the re-apply will correct any issues.
+ view.Post(() => ViewCompat.RequestApplyInsets(view));
+ }
}
// Calculate actual overlap for each edge
@@ -165,7 +249,7 @@ internal static class SafeAreaExtensions
if (top > 0 && viewTop < top && viewTop >= 0)
{
// Calculate the actual overlap amount
- top = Math.Min(top - viewTop, top);
+ top = System.Math.Min(top - viewTop, top);
}
else
{
@@ -178,7 +262,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var bottomEdge = screenHeight - bottom;
- bottom = Math.Min(viewBottom - bottomEdge, bottom);
+ bottom = System.Math.Min(viewBottom - bottomEdge, bottom);
}
else
{
@@ -192,7 +276,7 @@ internal static class SafeAreaExtensions
if (left > 0 && viewLeft < left)
{
// Calculate the actual overlap amount
- left = Math.Min(left - viewLeft, left);
+ left = System.Math.Min(left - viewLeft, left);
}
else
{
@@ -205,7 +289,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var rightEdge = screenWidth - right;
- right = Math.Min(viewRight - rightEdge, right);
+ right = System.Math.Min(viewRight - rightEdge, right);
}
else
{
Attempt 4 AnalysisTest ExecutionCommand: Build Status: ✅ SUCCESS (0 warnings, 0 errors)
Test Status: ✅ PASSED
Fix ImplementationApproach: ViewPager Detection + Position Cache Key Changes
Test Output EvidenceFrom test-output.log: ConclusionPASS - Fix successfully resolves the issue:
The fix eliminates continuous GC logs on Android for TabbedPage by intelligently gating inset requests for off-screen tabs. [PASS] Fix 5## Approach: ViewPager Parent DetectionDetects if a view is inside a ViewPager/ViewPager2 by walking the parent hierarchy. Skips inset requests for all ViewPager children since they are intentionally off-screen when not selected. Problem: Line 158 calls �iew.Post(() => ViewCompat.RequestApplyInsets(view)) continuously for inactive TabbedPage tabs positioned off-screen, creating infinite lambda allocations and GC pressure. Solution: Before requesting insets re-apply:
Different from attempt-4: Attempt-4 combined ViewPager detection with position caching and checked CurrentItem. This approach is simpler - just detect ViewPager presence and skip ALL off-screen views inside ViewPager. No state tracking, no position cache, no CurrentItem comparison. Simple parent traversal check only. Implementation:
diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..ba360b1e90 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -2,12 +2,31 @@ using System;
using Android.Content;
using Android.Views;
using AndroidX.Core.View;
+using AndroidX.ViewPager.Widget;
+using AndroidX.ViewPager2.Widget;
using Google.Android.Material.AppBar;
namespace Microsoft.Maui.Platform;
internal static class SafeAreaExtensions
{
+ /// <summary>
+ /// Checks if a view is inside a ViewPager or ViewPager2 by walking the parent hierarchy.
+ /// Views inside ViewPager are intentionally positioned off-screen when not selected,
+ /// so we should skip RequestApplyInsets for them to prevent infinite allocation loops.
+ /// </summary>
+ static bool IsInsideViewPager(View? view)
+ {
+ var parent = view?.Parent;
+ while (parent != null)
+ {
+ if (parent is ViewPager || parent is ViewPager2)
+ return true;
+ parent = (parent as View)?.Parent;
+ }
+ return false;
+ }
+
internal static ISafeAreaView2? GetSafeAreaView2(object? layout) =>
layout switch
{
@@ -151,11 +170,18 @@ internal static class SafeAreaExtensions
if (viewExtendsBeyondScreen)
{
- // Request insets to be reapplied after the next layout pass
- // when the view should be properly positioned.
- // Don't return early - let processing continue with current insets
- // to avoid visual popping, the re-apply will correct any issues.
- view.Post(() => ViewCompat.RequestApplyInsets(view));
+ // Skip RequestApplyInsets for views inside ViewPager/ViewPager2 (Issue #33731)
+ // These views are intentionally positioned off-screen when not selected.
+ // Continuously requesting insets for them causes infinite lambda allocations
+ // and GC pressure every 5-6 seconds.
+ if (!IsInsideViewPager(view))
+ {
+ // Request insets to be reapplied after the next layout pass
+ // when the view should be properly positioned.
+ // Don't return early - let processing continue with current insets
+ // to avoid visual popping, the re-apply will correct any issues.
+ view.Post(() => ViewCompat.RequestApplyInsets(view));
+ }
}
// Calculate actual overlap for each edge
AnalysisResult: Pass What happened: Test TabbedPageShouldNotCauseExcessiveGC passed on the first attempt. The test ran for 14 seconds and verified that the TabbedPage does not cause excessive GC activity. Why it worked: The ViewPager parent detection approach successfully prevents the infinite loop of RequestApplyInsets calls by:
Key insight: TabbedPage on Android uses ViewPager/ViewPager2 to implement tab swiping. Inactive tabs are positioned off-screen (e.g., at negative X coordinates or beyond screen width). By detecting this ViewPager container relationship, we can safely skip inset requests for these intentionally off-screen views without affecting normal layout scenarios. Difference from attempt-4: This approach is simpler - no position caching, no CurrentItem comparison. Just detect "is this view inside a ViewPager" and skip if true when off-screen. This reduces complexity and potential edge cases. Performance characteristics:
[PASS] Fix 6# ApproachThe issue is that The fix is to detect if the view is inside a Additionally, we ensured diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..03da0ae70d 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -127,8 +127,8 @@ internal static class SafeAreaExtensions
// If the Width and Height aren't set it means the layout pass hasn't happen yet
if (view.Width > 0 && view.Height > 0)
{
- viewTop = Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
- viewLeft = Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
+ viewTop = System.Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
+ viewLeft = System.Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
viewRight += (int)context.ToPixels(margins.Right);
viewBottom += (int)context.ToPixels(margins.Bottom);
}
@@ -149,7 +149,7 @@ internal static class SafeAreaExtensions
bool viewExtendsBeyondScreen = viewRight > screenWidth || viewBottom > screenHeight ||
viewLeft < 0 || viewTop < 0;
- if (viewExtendsBeyondScreen)
+ if (viewExtendsBeyondScreen && !IsInsideViewPager(view))
{
// Request insets to be reapplied after the next layout pass
// when the view should be properly positioned.
@@ -165,7 +165,7 @@ internal static class SafeAreaExtensions
if (top > 0 && viewTop < top && viewTop >= 0)
{
// Calculate the actual overlap amount
- top = Math.Min(top - viewTop, top);
+ top = System.Math.Min(top - viewTop, top);
}
else
{
@@ -178,7 +178,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var bottomEdge = screenHeight - bottom;
- bottom = Math.Min(viewBottom - bottomEdge, bottom);
+ bottom = System.Math.Min(viewBottom - bottomEdge, bottom);
}
else
{
@@ -192,7 +192,7 @@ internal static class SafeAreaExtensions
if (left > 0 && viewLeft < left)
{
// Calculate the actual overlap amount
- left = Math.Min(left - viewLeft, left);
+ left = System.Math.Min(left - viewLeft, left);
}
else
{
@@ -205,7 +205,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var rightEdge = screenWidth - right;
- right = Math.Min(viewRight - rightEdge, right);
+ right = System.Math.Min(viewRight - rightEdge, right);
}
else
{
@@ -314,4 +314,16 @@ internal static class SafeAreaExtensions
// - Any combination of the above flags
return originalSafeArea;
}
+
+ static bool IsInsideViewPager(View? view)
+ {
+ var parent = view?.Parent;
+ while (parent != null)
+ {
+ if (parent.GetType().Name.Contains("ViewPager", StringComparison.OrdinalIgnoreCase))
+ return true;
+ parent = parent.Parent;
+ }
+ return false;
+ }
}
AnalysisThe issue #33731 was caused by an infinite loop in The fix involves detecting if the view is a descendant of a We also replaced The fix was verified with the [PASS] Fix 7## Approach: Visible Area Guard for InsetsUse GetGlobalVisibleRect to determine if a view actually has visible pixels on screen. Track consecutive off-screen detections per view via a ConditionalWeakTable so only after multiple checks do we skip requesting new insets. When the view regains visibility the guard resets, and RequestApplyInsets is only posted when the view both extends beyond the screen and currently has visible content. Different from existing fix: Rather than unconditionally posting RequestApplyInsets when bounds exceed the screen, this approach gates the call on real visibility and multi-check state, preventing background tabs with zero visible area from scheduling infinite inset requests. diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..63b0e49311 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -1,4 +1,5 @@
using System;
+using System.Runtime.CompilerServices;
using Android.Content;
using Android.Views;
using AndroidX.Core.View;
@@ -8,6 +9,9 @@ namespace Microsoft.Maui.Platform;
internal static class SafeAreaExtensions
{
+ const int OffscreenChecksBeforeSkip = 2;
+ static readonly ConditionalWeakTable<View, OffscreenRequestState> s_ViewRequestStates = new();
+
internal static ISafeAreaView2? GetSafeAreaView2(object? layout) =>
layout switch
{
@@ -151,11 +155,21 @@ internal static class SafeAreaExtensions
if (viewExtendsBeyondScreen)
{
- // Request insets to be reapplied after the next layout pass
- // when the view should be properly positioned.
- // Don't return early - let processing continue with current insets
- // to avoid visual popping, the re-apply will correct any issues.
- view.Post(() => ViewCompat.RequestApplyInsets(view));
+ var visibleBounds = new global::Android.Graphics.Rect();
+ var hasVisibleArea = view.GetGlobalVisibleRect(visibleBounds) && !visibleBounds.IsEmpty;
+
+ if (ShouldRequestInsetsForView(view, hasVisibleArea))
+ {
+ // Request insets to be reapplied after the next layout pass
+ // when the view should be properly positioned.
+ // Don't return early - let processing continue with current insets
+ // to avoid visual popping, the re-apply will correct any issues.
+ view.Post(() => ViewCompat.RequestApplyInsets(view));
+ }
+ }
+ else
+ {
+ ResetOffscreenTracking(view);
}
// Calculate actual overlap for each edge
@@ -314,4 +328,35 @@ internal static class SafeAreaExtensions
// - Any combination of the above flags
return originalSafeArea;
}
+
+ static bool ShouldRequestInsetsForView(View view, bool hasVisibleArea)
+ {
+ if (!s_ViewRequestStates.TryGetValue(view, out var state))
+ {
+ state = new OffscreenRequestState();
+ s_ViewRequestStates.Add(view, state);
+ }
+
+ if (hasVisibleArea)
+ {
+ state.OffscreenChecks = 0;
+ return true;
+ }
+
+ state.OffscreenChecks++;
+ return state.OffscreenChecks < OffscreenChecksBeforeSkip;
+ }
+
+ static void ResetOffscreenTracking(View view)
+ {
+ if (s_ViewRequestStates.TryGetValue(view, out var state))
+ {
+ state.OffscreenChecks = 0;
+ }
+ }
+
+ class OffscreenRequestState
+ {
+ public int OffscreenChecks;
+ }
}
AnalysisResult: Pass The guard tracks consecutive off-screen detections per tabbed view using a ConditionalWeakTable. Only views that both extend beyond screen bounds and still report some visible area trigger RequestApplyInsets; views entirely off-screen must be visible again before rescheduling. Issue33731 test passed on Android, confirming GC-triggering requests stop while functionality remains intact. [PASS] Fix 8## Approach: Cached Runnable with RemoveCallbacksUse a cached Java.Lang.IRunnable per view and a main-thread Handler to coalesce RequestApplyInsets posts. Before posting, remove any pending callback for that runnable to avoid queue buildup and repeated lambda allocations while a view stays off-screen. Different from existing fix: avoids HashSet-based pending tracking and instead reuses a single runnable with RemoveCallbacks to prevent allocation churn. diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index ab692611b6..8c1589b52b 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -8,6 +8,13 @@ namespace Microsoft.Maui.Platform;
internal static class SafeAreaExtensions
{
+ static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable<View, RequestApplyInsetsRunnable> _applyInsetsRunnables = new();
+ static global::Android.OS.Handler? _insetsHandler;
+
+ static global::Android.OS.Handler InsetsHandler =>
+ _insetsHandler ??= new global::Android.OS.Handler(
+ global::Android.OS.Looper.MainLooper ?? throw new global::System.InvalidOperationException("MainLooper not available"));
+
internal static ISafeAreaView2? GetSafeAreaView2(object? layout) =>
layout switch
{
@@ -127,8 +134,8 @@ internal static class SafeAreaExtensions
// If the Width and Height aren't set it means the layout pass hasn't happen yet
if (view.Width > 0 && view.Height > 0)
{
- viewTop = Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
- viewLeft = Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
+ viewTop = System.Math.Max(0, viewTop - (int)context.ToPixels(margins.Top));
+ viewLeft = System.Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left));
viewRight += (int)context.ToPixels(margins.Right);
viewBottom += (int)context.ToPixels(margins.Bottom);
}
@@ -151,11 +158,21 @@ internal static class SafeAreaExtensions
if (viewExtendsBeyondScreen)
{
- // Request insets to be reapplied after the next layout pass
- // when the view should be properly positioned.
- // Don't return early - let processing continue with current insets
- // to avoid visual popping, the re-apply will correct any issues.
- view.Post(() => ViewCompat.RequestApplyInsets(view));
+ // Coalesce insets requests to avoid posting repeatedly while off-screen.
+ var runnable = _applyInsetsRunnables.GetValue(view, static v => new RequestApplyInsetsRunnable(v));
+ if (!runnable.RequestedWhileOffscreen)
+ {
+ runnable.RequestedWhileOffscreen = true;
+ var handler = view.Handler ?? InsetsHandler;
+ handler.RemoveCallbacks(runnable);
+ handler.Post(runnable);
+ }
+ }
+ else if (_applyInsetsRunnables.TryGetValue(view, out var runnable))
+ {
+ runnable.RequestedWhileOffscreen = false;
+ var handler = view.Handler ?? InsetsHandler;
+ handler.RemoveCallbacks(runnable);
}
// Calculate actual overlap for each edge
@@ -165,7 +182,7 @@ internal static class SafeAreaExtensions
if (top > 0 && viewTop < top && viewTop >= 0)
{
// Calculate the actual overlap amount
- top = Math.Min(top - viewTop, top);
+ top = System.Math.Min(top - viewTop, top);
}
else
{
@@ -178,7 +195,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var bottomEdge = screenHeight - bottom;
- bottom = Math.Min(viewBottom - bottomEdge, bottom);
+ bottom = System.Math.Min(viewBottom - bottomEdge, bottom);
}
else
{
@@ -192,7 +209,7 @@ internal static class SafeAreaExtensions
if (left > 0 && viewLeft < left)
{
// Calculate the actual overlap amount
- left = Math.Min(left - viewLeft, left);
+ left = System.Math.Min(left - viewLeft, left);
}
else
{
@@ -205,7 +222,7 @@ internal static class SafeAreaExtensions
{
// Calculate the actual overlap amount
var rightEdge = screenWidth - right;
- right = Math.Min(viewRight - rightEdge, right);
+ right = System.Math.Min(viewRight - rightEdge, right);
}
else
{
@@ -314,4 +331,23 @@ internal static class SafeAreaExtensions
// - Any combination of the above flags
return originalSafeArea;
}
+
+ sealed class RequestApplyInsetsRunnable : Java.Lang.Object, Java.Lang.IRunnable
+ {
+ readonly global::System.WeakReference<View> _view;
+ public bool RequestedWhileOffscreen { get; set; }
+
+ public RequestApplyInsetsRunnable(View view)
+ {
+ _view = new global::System.WeakReference<View>(view);
+ }
+
+ public void Run()
+ {
+ if (_view.TryGetTarget(out var view))
+ {
+ ViewCompat.RequestApplyInsets(view);
+ }
+ }
+ }
}
AnalysisResult: Pass What happened: Initial run failed to compile due to nullable MainLooper; fixed by guarding MainLooper. First test run failed GC threshold, second test run passed (Issue33731) after adding one-shot offscreen request gating. Why it worked/failed: Coalesced insets requests by reusing a cached Runnable per view and avoiding repeated posts while the view remains off-screen; resets when back on-screen to allow future requests. This prevents repeated allocations and reduces GC churn. Insights: RemoveCallbacks alone doesn't stop repeated posts when ApplyAdjustedSafeAreaInsetsPx is called frequently; gating by off-screen state avoids re-posting during idle. |
AI Model Consensus: Best Fix RecommendationFour AI models (Gemini 3 Pro, GPT-5.2-Codex, Claude Sonnet 4.5, Claude Opus 4.5) independently evaluated the 8 try-fix attempts above. Unanimous Winner: Fix 2 - Geometric Off-Screen Check
Why Fix 2 WinsAll models cited these reasons:
Concerns About Other Approaches
Recommendation: Adopt the geometric off-screen check approach from Fix 2. It directly addresses the root cause with minimal code and zero overhead. |
…nsets loop Views completely off-screen (e.g., inactive TabbedPage tabs) don't need insets applied. Simple bounds check eliminates the infinite loop without state tracking. Fixes #33731
|
/azp run maui-pr-uitests, maui-pr-devicetests |
|
Azure Pipelines successfully started running 2 pipeline(s). |
|
@copilot update title and description to match latest changes Please use the pr-finalizer skill to produce the title/description updates |
PR Finalization CompleteRecommended TitleRecommended DescriptionThe description should be updated to reflect the geometric off-screen check approach (Fix 2 from the AI analysis) that was implemented, replacing the original HashSet approach. Note Are you waiting for the changes in this PR to be merged? Root CauseOff-screen tab content in TabbedPage (e.g., inactive Tab 2 positioned at
Description of ChangeAdded geometric bounds check to detect completely off-screen views before posting if (viewExtendsBeyondScreen)
{
// Check if view is completely off-screen (no intersection with visible area)
bool isCompletelyOffScreen = viewLeft >= screenWidth || viewRight <= 0 ||
viewTop >= screenHeight || viewBottom <= 0;
if (!isCompletelyOffScreen)
{
// Only request insets if view has some intersection with screen
view.Post(() => ViewCompat.RequestApplyInsets(view));
}
}Philosophy:
Files Changed:
Key Technical DetailsWhy geometric check works:
Applies to any off-screen view:
What NOT to Do (for future agents)
VerificationTest approach:
Results:
Edge Cases
Issues FixedFixes #33731 Platforms Affected
|
…nd safety check Instead of checking type names, Shell can now opt-in to the transition re-apply behavior by implementing the internal IRequestInsetsOnTransition interface. Changes: - Add internal IRequestInsetsOnTransition interface (Android-only) - ShellFlyoutRenderer implements IRequestInsetsOnTransition - SafeAreaExtensions checks for interface with ConditionalWeakTable cache (automatically cleaned up when views are GC'd) - Keep isCompletelyOffScreen check as double safety - even for Shell, completely off-screen views don't need inset re-application The view.Post(() => RequestApplyInsets()) is now only called when: 1. View extends beyond screen bounds (transitioning) 2. View is NOT completely off-screen (has some visible intersection) 3. Parent hierarchy implements IRequestInsetsOnTransition (opt-in) Fixes #33731
…auiWindowInsetListener Moved the IRequestInsetsOnTransition lookup cache from a static ConditionalWeakTable to the MauiWindowInsetListener instance. This: - Avoids static state for per-view data - Naturally ties cache lifetime to the listener's lifecycle - Uses existing infrastructure (MauiWindowInsetListener is already per-view)
Added regression test to verify that pages with large scrollable content do not trigger excessive GC activity. Issue #33768 was closed as a duplicate of #33731, as both had the same root cause (PR #33285's viewExtendsBeyondScreen check). This test complements Issue33731's TabbedPage test by verifying that general scrollable content (ScrollView with 100 items) works correctly without triggering the infinite RequestApplyInsets loop.
Issue #33768 UITest: - Updated Issue33768.cs to use CollectionView with negative margin (Margin=-50) - This reproduces the exact scenario from the issue: negative margin causes native view bounds to extend beyond screen, triggering viewExtendsBeyondScreen - Test verifies fix prevents infinite RequestApplyInsets loop (0-2 GCs in 10s) GC Monitoring Infrastructure: - Created GCMonitoringService.cs for tracking GC activity in test app - Logs [MAUI_GC] entries to Console.WriteLine/logcat on Android - Integrated into MauiProgram startup and test page navigation - Added LogGCCountFromDeviceLogs() in UITestBase.TestTearDown() - Parses [MAUI_GC] entries and logs per-test GC count to test output - Warns if GC count >= 5 (potential memory issue) This enables baseline tracking of GC activity across all UI tests to catch performance regressions like those in issues #33731 and #33768.
|
/azp run maui-pr-uitests, maui-pr-devicetests |
|
Azure Pipelines successfully started running 2 pipeline(s). |
- Add GetRequestInsetsAction() to cache Action delegate to avoid allocations - Add IsContentViewLayout() helper to limit transition insets to ContentPage types - Improve comments explaining the three safety checks: 1. isCompletelyOffScreen - skip if no visible intersection 2. IRequestInsetsOnTransition - opt-in for Shell hierarchy only 3. IsContentViewLayout - limit to ContentView/ContentPage types Note: Testing showed that caching the delegate alone does NOT fix GC pressure because the allocations come from View.Post() and RequestApplyInsets() internals. The infinite loop prevention is the essential fix.
|
/azp run maui-pr-uitests, maui-pr-devicetests |
|
Azure Pipelines successfully started running 2 pipeline(s). |
- Add IContentPageController marker interface in IHandleWindowInsets.cs - ContentPage implements IContentPageController on Android only - Update IsContentPageLayout() to check interface instead of type name - This is the proper pattern - never use GetType() for type checking
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
|
/azp run maui-pr-uitests |
|
/azp run maui-pr-devicetests |
|
Azure Pipelines successfully started running 1 pipeline(s). |
1 similar comment
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
alternate fix here #33788 |
| return; | ||
|
|
||
| var (gen0, gen1, gen2) = GetCurrentCounts(); | ||
| var elapsed = (DateTime.UtcNow - _startTime).TotalSeconds; |
There was a problem hiding this comment.
@copilot anything you're doing in here with DateTime math should be using the Stopwatch class instead.
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 from this PR and let us know in a comment if this change resolves your issue. Thank you!
Root Cause
Off-screen tab content in TabbedPage (e.g., inactive Tab 2 positioned at
viewLeft=1080whenscreenWidth=1080) continuously triggeredRequestApplyInsets()in an infinite loop:viewExtendsBeyondScreen = trueview.Post(() => ViewCompat.RequestApplyInsets(view))Description of Change
Added geometric bounds check to detect completely off-screen views before posting
RequestApplyInsets:Philosophy:
Files Changed:
SafeAreaExtensions.cs(+10 lines) - AddedisCompletelyOffScreencheckIssue33731.cs(HostApp) - TabbedPage test with GC monitoringIssue33731.cs(Tests) - NUnit test verifying GC count stays under thresholdKey Technical Details
Why geometric check works:
viewExtendsBeyondScreenalready calculates view bounds (viewLeft,viewRight,viewTop,viewBottom)[0, 0, screenWidth, screenHeight]viewLeft >= screenWidth→ entirely to the rightviewRight <= 0→ entirely to the leftviewTop >= screenHeight→ entirely belowviewBottom <= 0→ entirely aboveApplies to any off-screen view:
What NOT to Do (for future agents)
Verification
Test approach:
GC.CollectionCount(0)over 10 secondsResults:
Edge Cases
!isCompletelyOffScreenensures partial views still get insetsPlatforms Affected
Original prompt
"please create a PR with these changes and have copilot review"
[Chronological Review: The conversation started with the user requesting help with GitHub issue #33731 (TabbedPage continuous GC logs). Phases: 1) Branch creation and issue analysis, 2) UI test creation, 3) Sandbox reproduction with instrumentation, 4) Bug confirmation (7000+ RequestApplyInsets calls), 5) Fix implementation in SafeAreaExtensions.cs, 6) Fix verification via logcat (0 GC events vs 10+), 7) Test threshold adjustment, 8) SafeArea regression testing, 9) Discussion about test quality][Intent Mapping: User wanted to: create branch for issue #33731, write UI tests for reproduction, instrument MAUI code for debugging, reproduce via Sandbox app, fix the infinite loop bug, verify fix doesn't break existing functionality, and assess test quality]
[Technical Inventory: .NET MAUI Android, SafeAreaExtensions.cs, TabbedPage, ViewCompat.RequestApplyInsets, GlobalLayout events, Appium UI testing, Android logcat, HashSet for pending request tracking, view.GetHashCode() for view identification]
[Code Archaeology: SafeAreaExtensions.cs (added _pendingInsetRequests HashSet, modified viewExtendsBeyondScreen logic), Issue33731.cs HostApp (TabbedPage with GlobalLayout monitoring), Issue33731.cs test (NUnit test checking layout count), App.xaml.cs Sandbox (configured for TabbedPage reproduction)]
[Progress Assessment: Completed - branch creation, UI test creation, bug reproduction, fix implementation, fix verification via GC logs, SafeArea regression tests (22/24 passed). Current - discussing test quality concerns]
[Context Validation: The fix works (verified by 0 GC events vs 10+), but the UI test is weak because GlobalLayout events don't correlate with the actual bug]
[Recent Commands Analysis: The most recent exchange was the user asking "do you feel confident that the UITest does a decent enough job reproducing the issue?" - no tool was called, just an assessment response admitting the test is weak]
1. Conversation Overview: - Primary Objectives: User requested "I want to start working on this issue https://github.com//issues/33731 can you check out a new branch and then run the write-ui-tests skill so we can get a reproduction of it". Goal was to fix Android TabbedPage continuous GC regression caused by PR #33285. - Session Context: Started with issue investigation, progressed through UI test creation, Sandbox reproduction, bug confirmation via device logs, fix implementation, and verification. Final discussion about test quality. - User Intent Evolution: Initially wanted UI tests for reproduction → expanded to Sandbox instrumentation → insisted on continuing until reproduction achieved → accepted fix approach with logcat verification → questioned test effectiveness-
- .NET MAUI 10.0: Android platform, TabbedPage control
- SafeAreaExtensions.cs: Core file containing the bug at lines 149-159
- Bug Mechanism:
- Root Cause: Tab 2 content positioned at viewLeft=1080 (screen width) triggers viewExtendsBeyondScreen=true continuously
- Android Diagnostics: adb logcat for GC and SAFEAREA message monitoring
-
-
- Purpose: Handles safe area insets for Android views
- Current State: Fixed - added HashSet tracking for pending inset requests
- Key Code: Lines 12-15 (HashSet declaration), Lines 159-180 (fix logic checking/adding/removing from pending set)
- Fix Pattern: Track view.GetHashCode() in _pendingInsetRequests, only post RequestApplyInsets if not already pending, remove when view is on-screen
-
- Purpose: UI test reproduction page
- Current State: TabbedPage with GlobalLayout monitoring via ViewTreeObserver
- Key Code: Timer updates LayoutCountLabel every 100ms, HandlerChanged hooks GlobalLayout event
-
- Purpose: NUnit Appium test
- Current State: Checks layout count after 3 seconds, threshold of 500
- Key Code: Assert.That(layoutsDuringIdle, Is.LessThan(500))
-
- Purpose: Sandbox reproduction app
- Current State: Modified to use Issue33731TabbedPage matching the issue reproduction
-
- Issues Encountered: Infinite loop in SafeAreaExtensions causing 7000+ RequestApplyInsets calls and GC every 5-6 seconds
- Solutions Implemented: HashSet _pendingInsetRequests tracks views with pending requests; only posts new request if view not in set; clears when view positioned on-screen
- Debugging Context: Used Console.WriteLine instrumentation, adb logcat monitoring, GlobalLayout even...
Technical Foundation:
view.Post(() => ViewCompat.RequestApplyInsets(view))called infinitely for off-screen tab contentCodebase Status:
src/Core/src/Platform/Android/SafeAreaExtensions.cs:src/Controls/tests/TestCases.HostApp/Issues/Issue33731.cs:src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33731.cs:src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs:Problem Resolution:
Created from Copilot CLI via the copilot delegate command.
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.