Skip to content

[iOS] Fix SafeArea infinite layout cycle with parent hierarchy walk and pixel-level comparison#34024

Merged
PureWeen merged 16 commits intomainfrom
ios-safearea-infinite-layout-fix
Mar 6, 2026
Merged

[iOS] Fix SafeArea infinite layout cycle with parent hierarchy walk and pixel-level comparison#34024
PureWeen merged 16 commits intomainfrom
ios-safearea-infinite-layout-fix

Conversation

@PureWeen
Copy link
Copy Markdown
Member

@PureWeen PureWeen commented Feb 12, 2026

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

SafeAreaInsetsDidChange fires repeatedly during iOS animations (e.g., TranslateToAsync, bottom sheet transitions) as views move relative to the window. This caused two distinct infinite loop patterns:

  1. Sub-pixel oscillation (Layout issue using TranslateToAsync causes infinite property changed cycle on iOS #32586, [iOS] TranslateToAsync causes spurious SizeChanged events after animation completion, triggering infinite layout loops #33934): Animations produce sub-pixel differences in SafeAreaInsets (e.g., 0.0000001pt). Exact equality fails, triggering InvalidateAncestorsMeasures → layout pass → position change → new SafeAreaInsetsDidChange → infinite loop.

  2. Parent-child double application ([net10] iOS 18.6 crashing on navigating to a ContentPage with Padding set and Content set to a <Grid RowDefinitions="*,Auto"> with ScrollView on row 0 #33595): A ContentPage (implementing ISafeAreaView) and its child Grid both independently apply safe area adjustments. When the ContentPage adjusts its layout for the notch/status bar, it repositions the Grid. The Grid's new position fires SafeAreaInsetsDidChange, causing it to re-apply its own adjustment — creating a ping-pong loop.

Description of Change

Primary fix — IsParentHandlingSafeArea (parent hierarchy walk):

In both MauiView.ValidateSafeArea and MauiScrollView.ValidateSafeArea, before applying safe area adjustments, we now check whether an ancestor MauiView is already applying safe area for the same edges. If so, the child skips its own adjustment to avoid double-padding.

The check is edge-aware: a parent handling Top does not block a child from independently handling Bottom. Only overlapping edges cause deferral. The _parentHandlesSafeArea result is cached per layout cycle and cleared on SafeAreaInsetsDidChange, InvalidateSafeArea, and MovedToWindow.

Secondary fix — EqualsAtPixelLevel:

Safe area values are compared at device-pixel resolution (rounding to 1 / ContentScaleFactor) before deciding whether to trigger a layout invalidation. This absorbs sub-pixel animation noise and prevents the oscillation loops in #32586 and #33934.

MauiScrollView bug fixes:

  • Inverted condition: !UpdateContentInsetAdjustmentBehavior() was incorrectly gating behavior; corrected to UpdateContentInsetAdjustmentBehavior().
  • The _appliesSafeAreaAdjustments flag now correctly incorporates !IsParentHandlingSafeArea().

What was removed:

  • The "Window Guard" approach (comparing Window.SafeAreaInsets to filter noise) was tried and removed. It was fragile: on macCatalyst with a custom TitleBar, WindowViewController repositions content by pushing it down, which changes the view's own SafeAreaInsets without changing Window.SafeAreaInsets. The guard blocked this legitimate change, causing a 28px content shift regression in CI.

Issues Fixed

Fixes #32586
Fixes #33934
Fixes #33595
Fixes #34042

Copilot AI review requested due to automatic review settings February 12, 2026 18:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an iOS infinite layout cycle that occurs when nested views both have SafeAreaEdges = Container by implementing a generation counter mechanism to track SafeArea change events.

Changes:

  • Implemented a generation counter system using a static counter incremented on genuine SafeArea changes and per-view tracking to prevent re-invalidation within the same generation
  • Added test cases for nested SafeArea views with animations (Issue32586) and runtime SafeAreaEdges toggling (Issue33595)

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Core/src/Platform/iOS/MauiView.cs Added generation counter fields and logic to break infinite layout cycles in LayoutSubviews
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33595.cs UI test verifying navigation with padding and ScrollView doesn't crash
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32586.cs UI tests for TranslateToAsync animation and runtime SafeAreaEdges toggling
src/Controls/tests/TestCases.HostApp/Issues/Issue33595.cs Test page with Grid containing ScrollView that previously caused freezing
src/Controls/tests/TestCases.HostApp/Issues/Issue32586.cs Test page with nested SafeArea views and animation triggers

@PureWeen
Copy link
Copy Markdown
Member Author

Test Results ✅

Successfully fixed the infinite layout cycle! The fix now passes all tests:

Issue32586 Test (the freeze repro)

  • Before: App froze indefinitely, test hung forever
  • After: Test passes ✅

All SafeAreaEdges Tests

  • Result: All passed ✅

How the Fix Works

The root cause was that InvalidateMeasure() sets _safeAreaInvalidated = true on ancestor views. This caused ValidateSafeArea() to run again on the next LayoutSubviews even when safe area values hadn't changed, creating a cycle.

The fix uses a global generation counter that increments each time any view invalidates ancestors from LayoutSubviews. Each view tracks the generation at which it last invalidated. If asked to layout again at the same generation (we're in a cycle), it skips the invalidation.

This preserves correct SafeArea behavior while breaking the infinite loop.

Bonus: Test Infrastructure Improvements

Also added resilience fixes to prevent tests from hanging indefinitely when apps freeze:

  1. Process-level timeout in BuildAndRunHostApp.ps1 (5 minutes)
  2. Property getter timeouts for GetText/GetAttribute/GetRect/IsSelected
  3. Teardown timeouts for App.AppState queries

These ensure tests fail gracefully instead of hanging, making it much easier to debug layout issues like this one.

@PureWeen
Copy link
Copy Markdown
Member Author

/rebase

@github-actions github-actions bot force-pushed the ios-safearea-infinite-layout-fix branch from 42adf00 to ba9c901 Compare February 19, 2026 13:19
@PureWeen
Copy link
Copy Markdown
Member Author

/azp run maui-pr-uitests, maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 2 pipeline(s).

PureWeen added a commit that referenced this pull request Feb 19, 2026
…n oscillation fix

Cherry-picked from PR #34024 (ios-safearea-infinite-layout-fix):
- MauiView.cs: generation counter to break parent↔child safe area cycles
- MauiView.cs: global rate limiter for safe area invalidation cascades
- Tests: Issue32586, Issue33595, Issue33934 (safe area layout cycle tests)

Additional fix for Issue33934 animation-driven layout oscillation:
- InvalidateMeasure(isPropagating: true) no longer sets _safeAreaInvalidated
- A descendant changing size/transform doesn't affect system safe area insets
- This prevented spurious safe area revalidation during TranslateToAsync
  animations, which caused measurement oscillation (±2px) and infinite
  SizeChanged → cancel animation → restart cycles

Validated locally:
- Issue33934: was infinite loop, now completes in ≤2 iterations ✅
- Issue33595: simple layout cycle fix ✅
- Issue32586: safe area layout ✅
- Issue18896: existing safe area test ✅
- Issue33458: another safe area test ✅
- SafeAreaEdges category: all 28 tests pass ✅

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Feb 19, 2026
Remove MauiView.cs safe area changes, Issue33934/32586/33595 test
files, and ViewModelBase changes from this PR. These belong in PR
#34024 (ios-safearea-infinite-layout-fix) which is the dedicated
safe area fix PR.

This PR now focuses solely on UITest resilience infrastructure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Feb 19, 2026
…n oscillation fix

Cherry-picked from PR #34024 (ios-safearea-infinite-layout-fix):
- MauiView.cs: generation counter to break parent↔child safe area cycles
- MauiView.cs: global rate limiter for safe area invalidation cascades
- Tests: Issue32586, Issue33595, Issue33934 (safe area layout cycle tests)

Additional fix for Issue33934 animation-driven layout oscillation:
- InvalidateMeasure(isPropagating: true) no longer sets _safeAreaInvalidated
- A descendant changing size/transform doesn't affect system safe area insets
- This prevented spurious safe area revalidation during TranslateToAsync
  animations, which caused measurement oscillation (±2px) and infinite
  SizeChanged → cancel animation → restart cycles

Validated locally:
- Issue33934: was infinite loop, now completes in ≤2 iterations ✅
- Issue33595: simple layout cycle fix ✅
- Issue32586: safe area layout ✅
- Issue18896: existing safe area test ✅
- Issue33458: another safe area test ✅
- SafeAreaEdges category: all 28 tests pass ✅

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Feb 19, 2026
Remove MauiView.cs safe area changes, Issue33934/32586/33595 test
files, and ViewModelBase changes from this PR. These belong in PR
#34024 (ios-safearea-infinite-layout-fix) which is the dedicated
safe area fix PR.

This PR now focuses solely on UITest resilience infrastructure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Feb 19, 2026
…n oscillation fix

Cherry-picked from PR #34024 (ios-safearea-infinite-layout-fix):
- MauiView.cs: generation counter to break parent↔child safe area cycles
- MauiView.cs: global rate limiter for safe area invalidation cascades
- Tests: Issue32586, Issue33595, Issue33934 (safe area layout cycle tests)

Additional fix for Issue33934 animation-driven layout oscillation:
- InvalidateMeasure(isPropagating: true) no longer sets _safeAreaInvalidated
- A descendant changing size/transform doesn't affect system safe area insets
- This prevented spurious safe area revalidation during TranslateToAsync
  animations, which caused measurement oscillation (±2px) and infinite
  SizeChanged → cancel animation → restart cycles

Validated locally:
- Issue33934: was infinite loop, now completes in ≤2 iterations ✅
- Issue33595: simple layout cycle fix ✅
- Issue32586: safe area layout ✅
- Issue18896: existing safe area test ✅
- Issue33458: another safe area test ✅
- SafeAreaEdges category: all 28 tests pass ✅

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen added a commit that referenced this pull request Feb 19, 2026
Remove MauiView.cs safe area changes, Issue33934/32586/33595 test
files, and ViewModelBase changes from this PR. These belong in PR
#34024 (ios-safearea-infinite-layout-fix) which is the dedicated
safe area fix PR.

This PR now focuses solely on UITest resilience infrastructure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 3 pipeline(s).

@PureWeen PureWeen changed the title Fix iOS infinite layout cycle with nested SafeArea views using generation counter [iOS] MauiView: Round SafeArea insets to pixels to fix infinite layout cycle Feb 20, 2026
@PureWeen
Copy link
Copy Markdown
Member Author

Alright @Tamilarasan-Paranthaman @sheiksyedm

Let me know what you think of this fix

@PureWeen
Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 3 pipeline(s).

@PureWeen
Copy link
Copy Markdown
Member Author

PureWeen commented Feb 20, 2026

🔬 Fix Approach Comparison — 4 Independent Attempts

We ran 4 independent try-fix attempts, each proposing a fundamentally different approach to fix the iOS safe area infinite layout cycle. After retesting with the updated UITests (with [Order] attributes and state-reset logic), only Approach 3 passes all tests.

Retest Results (Updated UITests)

# Approach Filters By Issue32586 (3 tests) Issue33934 Overall
1 Epsilon tolerance VALUE ❌ 2/3 ❌ FAIL ❌ FAIL
2 Layout guard CONTEXT ❌ 2/3 ✅ PASS ⚠️ Partial
3 Window-level comparison SOURCE ✅ 3/3 ✅ PASS ✅ PASS
4 Time-based debounce TIME ❌ 2/3 ❌ FAIL ❌ FAIL

Common failure in approaches 1, 2, 4: VerifyFooterPositionRespectsSafeArea — footer stays ~33pt above screen bottom despite SafeAreaEdges=None, proving safe area is still being applied incorrectly.

✅ Selected: Approach 3 — Window-Level Safe Area Comparison (applied in 3498ef8)

All approaches also fix a pre-existing bug in MauiScrollView.cs where _safeAreaInvalidated = true should be _safeAreaInvalidated = false in ValidateSafeArea() (the flag was never being cleared after validation).


✅ Approach 3: Window-Level Comparison (SELECTED — filters by SOURCE)

Concept

Compare Window.SafeAreaInsets (device-level: status bar, home indicator) in SafeAreaInsetsDidChange(). During animations, a view's own SafeAreaInsets fluctuate, but Window.SafeAreaInsets stay constant. If Window insets haven't changed, the event is animation noise → skip it entirely.

Why it works

Changes

  • MauiView.cs: Added _lastWindowSafeAreaInsets field, Window comparison in SafeAreaInsetsDidChange(), reset in MovedToWindow()
  • MauiScrollView.cs: Same pattern + _safeAreaInvalidated bug fix + MovedToWindow() reset

Key code

UIEdgeInsets _lastWindowSafeAreaInsets;

public override void SafeAreaInsetsDidChange()
{
    if (Window is not null)
    {
        var windowInsets = Window.SafeAreaInsets;
        if (windowInsets == _lastWindowSafeAreaInsets)
            return;
        _lastWindowSafeAreaInsets = windowInsets;
    }
    _safeAreaInvalidated = true;
    base.SafeAreaInsetsDidChange();
}

Pros

  • Semantically clear: filters by the SOURCE of truth for device safe areas
  • No magic numbers, no structural changes
  • Only genuine safe area events (rotation, keyboard, status bar) pass through
  • Keyboard events handled separately via OnKeyboardWillShow/Hide

Cons

  • Assumes Window.SafeAreaInsets never changes during animations (should hold but is UIKit implementation detail)
  • Requires MovedToWindow() override to reset cached value
❌ Approach 1: Epsilon-Based Tolerance (FAILED retest — filters by VALUE)

Concept

Use epsilon-based tolerance comparison (0.5pt) to absorb sub-pixel noise from animations. Values that differ by less than epsilon are treated as unchanged.

Retest Result

  • Issue32586: ❌ VerifyFooterPositionRespectsSafeArea failed — footer 33pt short of screen bottom
  • Issue33934: ❌ Timed out — infinite animation loop not prevented

Why it failed

Epsilon comparison only filters sub-pixel noise at the value level but doesn't prevent the SafeAreaInsetsDidChange callback from firing and setting _safeAreaInvalidated = true. The safe area values can still differ by more than epsilon during animations.

⚠️ Approach 2: Layout Guard (PARTIAL — filters by CONTEXT)

Concept

Track when inside LayoutSubviews() with _isInLayoutSubviews flag. Skip ancestor invalidation during layout passes.

Retest Result

  • Issue32586: ❌ VerifyFooterPositionRespectsSafeArea failed — footer 33pt short of screen bottom
  • Issue33934: ✅ PASS — animation completes

Why it partially failed

The layout guard prevents the infinite cycle (33934 passes) but doesn't prevent safe area values from being applied when they shouldn't be. The flag only gates invalidation, not the safe area calculation itself.

❌ Approach 4: Time-Based Debounce (FAILED retest — filters by TIME)

Concept

Coalesce rapid SafeAreaInsetsDidChange calls using a 16ms debounce window (~1 frame).

Retest Result

  • Issue32586: ❌ VerifyFooterPositionRespectsSafeArea failed — footer 33pt short of screen bottom
  • Issue33934: ❌ Timed out — infinite animation loop not prevented

Why it failed

Time-based filtering still allows the first SafeAreaInsetsDidChange per frame through, which is enough to trigger the invalidation cycle. The problem isn't rate-of-change but that animation-induced changes shouldn't trigger invalidation at all.

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 2 pipeline(s).

kubaflo
kubaflo previously approved these changes Mar 4, 2026
@github-project-automation github-project-automation bot moved this from Todo to Approved in MAUI SDK Ongoing Mar 4, 2026
…e RTL mirroring

The CrossPlatformArrange call with negative X offset placed content outside the
scrollable range, making it unreachable. iOS UIScrollView with SemanticContentAttribute
ForceRightToLeft handles RTL mirroring natively. Only ContentOffset needs to be set
to position the initial scroll at the RTL start.

The removed if/else branches were identical dead code from a merge conflict resolution.

Fixes ScrollViewShouldWorkInRTL / Issue29458 test failure on iOS vlatest.

Co-authored-by: Tamilarasan-Paranthaman <Tamilarasan-Paranthaman@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
Copy Markdown
Member Author

PureWeen commented Mar 5, 2026

/azp run maui-pr-uitests

@PureWeen
Copy link
Copy Markdown
Member Author

PureWeen commented Mar 5, 2026

/azp run maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

1 similar comment
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@PureWeen
Copy link
Copy Markdown
Member Author

PureWeen commented Mar 5, 2026

/azp run maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@PureWeen
Copy link
Copy Markdown
Member Author

PureWeen commented Mar 5, 2026

/azp run maui-pr-uitests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@PureWeen PureWeen merged commit 6c123d7 into main Mar 6, 2026
161 checks passed
@PureWeen PureWeen deleted the ios-safearea-infinite-layout-fix branch March 6, 2026 15:33
@github-project-automation github-project-automation bot moved this from Approved to Done in MAUI SDK Ongoing Mar 6, 2026
PureWeen pushed a commit that referenced this pull request Apr 2, 2026
- Replace non-existent PR numbers (#34000, #33500, #33000, #34100)
  with real merged PRs (#34024, #34727, #31202, #28713, #34723)
- Add "in dotnet/maui" to all prompts to prevent agent asking for repo
- All PRs verified as real merged PRs with actual code changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-safearea Issues/PRs that have to do with the SafeArea functionality copilot platform/ios s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

Status: Done

5 participants