Skip to content

Comments

[iOS] MauiView: Round SafeArea insets to pixels to fix infinite layout cycle#34024

Open
PureWeen wants to merge 37 commits intomainfrom
ios-safearea-infinite-layout-fix
Open

[iOS] MauiView: Round SafeArea insets to pixels to fix infinite layout cycle#34024
PureWeen wants to merge 37 commits intomainfrom
ios-safearea-infinite-layout-fix

Conversation

@PureWeen
Copy link
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

TranslateToAsync animations on iOS cause SafeAreaInsetsDidChange to fire repeatedly as the view moves relative to the window. These updates often contain sub-pixel differences (e.g., 0.0000001pt). Because SafeAreaPadding uses double precision, exact equality checks fail (oldSafeArea == newSafeArea returns false), triggering:

  1. InvalidateAncestorsMeasures
  2. Layout pass
  3. Position change
  4. New SafeAreaInsetsDidChange event
  5. Infinite Loop (app freeze)

Description of Change

In MauiView.ValidateSafeArea and MauiScrollView.ValidateSafeArea, we now round the safe area insets to the nearest device pixel using ContentScaleFactor.

This stabilizes the values by absorbing sub-pixel oscillation into the same pixel bucket. The comparison oldSafeArea == newSafeArea now returns true for sub-pixel changes, preventing unnecessary layout invalidation and breaking the cycle.

Refinements:

  • Used ContentScaleFactor instead of Window.Screen.Scale for safer access.
  • Applied fix to MauiScrollView to prevent latent cycles there.
  • Reverted previous complex fixes (Window guard, isPropagating guard, cycle-break flag) in favor of this simpler, more robust solution.

Issues Fixed

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

Platforms Tested

  • iOS (Validated with new regression tests)
  • Android
  • Windows
  • Mac

Copilot AI review requested due to automatic review settings February 12, 2026 18:20
Copy link
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

// Global counter incremented each time any MauiView receives SafeAreaInsetsDidChange.
// Used to detect when a ValidateSafeArea "changed" result is due to the same safe area
// event being re-processed (infinite cycle) vs. a genuinely new safe area change.
static int s_safeAreaGeneration;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The static counter s_safeAreaGeneration is accessed from multiple threads (read in LayoutSubviews() at line 565 and incremented atomically in SafeAreaInsetsDidChange() at line 730), but the read operation is not atomic. Consider using Interlocked.Read() or Volatile.Read() when accessing this counter to ensure thread-safe reads.

Suggested change
static int s_safeAreaGeneration;
static volatile int s_safeAreaGeneration;

Copilot uses AI. Check for mistakes.
@PureWeen
Copy link
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
Member Author

/rebase

PureWeen and others added 5 commits February 19, 2026 13:19
…tion counter

When both a parent and child view respond to SafeArea (both have SafeAreaEdges = Container),
an infinite layout cycle occurs: each view's layout change triggers the other to re-validate,
creating an endless loop that freezes the app.

Root cause: Both views apply SafeArea padding independently. Each layout pass invalidates
ancestors, which triggers another layout pass, ad infinitum.

Previous approach (PR #32797): Check if parent handles SafeArea via hierarchy walk. Problem:
This silences ALL child SafeArea edges when parent handles ANY edge, breaking nested SafeArea
scenarios where parent handles top and child independently handles bottom.

This fix: Use a generation counter incremented on genuine SafeAreaInsetsDidChange events.
Each view tracks the last generation where it invalidated ancestors. If already invalidated
for current generation, skip re-invalidation and just arrange.

Benefits:
- Breaks the cycle: within a single SafeArea change event, each view only invalidates once
- Preserves correct behavior: both parent and child can still apply their own SafeArea edges
- Simpler logic: no hierarchy walking, just atomic counter check

Test cases (Issue32586, Issue33595):
- Nested Grid + VerticalStackLayout with SafeArea=Container
- TranslateToAsync animation that previously triggered infinite cycle
- Runtime SafeAreaEdges toggling

Fixes #32586
Fixes #33595
When an app enters an infinite loop (e.g., layout cycle), WDA (WebDriverAgent)
blocks forever waiting for the main thread to become idle. This causes Appium
commands to hang indefinitely, which in turn hangs the entire test run.

Changes:

**HelperExtensions.cs:**
- Add RunWithTimeout wrapper (45s hard limit) around all Appium commands
- Wrap Tap(), Click(), and Wait() query calls with timeout
- Uses CancellationTokenSource for proper cleanup hygiene
- Logs warning about orphaned threads when timeout occurs
- Throws clear TimeoutException when app is unresponsive
- Uses Task.Run + GetAwaiter().GetResult() to properly unwrap exceptions

**AppiumLifecycleActions.cs:**
- Add ForceCloseApp command using OS-level termination
  - iOS: xcrun simctl terminate
  - Android: adb shell am force-stop
  - Mac: osascript quit
- Wrap CloseApp with 15s timeout, auto-falls back to ForceCloseApp
- Bypasses WDA when normal Appium termination hangs

**UITestBase.cs:**
- Catch TimeoutException for unresponsive apps in TearDown
- Force-terminate and reset session when app freezes
- Allows subsequent tests to continue instead of hanging forever

Result: Tests now fail gracefully after 45s with clear error message
instead of hanging indefinitely when app freezes.
- Added 5-minute process-level timeout to BuildAndRunHostApp.ps1
  PowerShell script using Start-Job + Wait-Job pattern
- Wrapped GetText(), GetAttribute(), GetRect(), IsSelected()
  property getters with RunWithTimeout in HelperExtensions.cs
- Wrapped App.AppState query and SaveUIDiagnosticInfo with
  Task.Run + timeout in UITestBase.UITestBaseTearDown
- Fixed TimeoutException handling to properly propagate to
  unresponsive app handler

These changes ensure UI tests fail gracefully when the app
becomes unresponsive (e.g., infinite layout loop) instead of
hanging indefinitely. The script-level timeout is the ultimate
safety net that kills the test process if it runs too long.

Fixes test hangs for issues #32586 and #33595 SafeArea tests.
When both parent and child views respond to SafeArea changes (both have
SafeAreaEdges=Container), an infinite layout cycle can occur where each
view's layout invalidation triggers the other to re-layout indefinitely.

Root cause: InvalidateMeasure() sets _safeAreaInvalidated=true on ancestor
views, causing ValidateSafeArea() to run again even when safe area values
haven't changed. Each LayoutSubviews calls InvalidateAncestorsMeasures,
which propagates back up and triggers another cycle.

Fix: Use a global generation counter incremented 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 (meaning we're in a cascade/cycle), skip the invalidation.

This allows both parent and child to respond to safe area correctly on
genuine safe area changes, while breaking the cycle when they trigger
each other's layout repeatedly.

Fixes #32586
Fixes #33595
@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
Member Author

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

@azure-pipelines
Copy link

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>
…s guard

iOS fires SafeAreaInsetsDidChange when a view's position changes during
animations, not just for genuine system safe area changes. Per-view
SafeAreaInsets reflect position relative to safe area boundaries, so
they change whenever the view moves — causing measurement oscillation
and infinite layout cycles when combined with SizeChanged handlers
that restart animations (see #33934).

Fix uses two complementary mechanisms:

1. SafeAreaInsetsDidChange: Compare Window's safe area insets (which
   only change for genuine system events like keyboard/rotation)
   instead of per-view insets. Blocks animation-driven noise.

2. InvalidateMeasure: When propagating from descendants, only set
   _safeAreaInvalidated if the global safe area generation advanced
   (meaning a parent's safe area path ran). This ensures descendants
   pick up runtime SafeAreaEdges changes while staying inactive during
   animation cycles (where no safe area path runs).

Fixes #33934

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 and others added 7 commits February 19, 2026 14:20
When an app enters an infinite loop (e.g., layout cycle), WDA (WebDriverAgent)
blocks forever waiting for the main thread to become idle. This causes Appium
commands to hang indefinitely, which in turn hangs the entire test run.

Changes:

**HelperExtensions.cs:**
- Add RunWithTimeout wrapper (45s hard limit) around all Appium commands
- Wrap Tap(), Click(), and Wait() query calls with timeout
- Uses CancellationTokenSource for proper cleanup hygiene
- Logs warning about orphaned threads when timeout occurs
- Throws clear TimeoutException when app is unresponsive
- Uses Task.Run + GetAwaiter().GetResult() to properly unwrap exceptions

**AppiumLifecycleActions.cs:**
- Add ForceCloseApp command using OS-level termination
  - iOS: xcrun simctl terminate
  - Android: adb shell am force-stop
  - Mac: osascript quit
- Wrap CloseApp with 15s timeout, auto-falls back to ForceCloseApp
- Bypasses WDA when normal Appium termination hangs

**UITestBase.cs:**
- Catch TimeoutException for unresponsive apps in TearDown
- Force-terminate and reset session when app freezes
- Allows subsequent tests to continue instead of hanging forever

Result: Tests now fail gracefully after 45s with clear error message
instead of hanging indefinitely when app freezes.
Add robust checks for external process execution and improve timeout/error handling.

- AppiumLifecycleActions: verify process is non-null, WaitForExit succeeded, and ExitCode == 0 for xcrun/adb/osascript calls; log failures and return FailedEmptyResponse instead of assuming success.
- HelperExtensions: attach a continuation to orphaned Appium tasks (OnlyOnFaulted) to observe and log future exceptions, preventing unobserved task exceptions.
- UITestBase: explicitly rethrow TimeoutException from AppState probe so unresponsive-app timeouts bubble to the outer handler, and simplify the outer TimeoutException catch to handle timeout cases consistently.

These changes prevent false-success when command-line helpers fail, surface background task faults, and ensure unresponsive app timeouts are handled reliably.
- Fix Wait() timeout composition: inner RunWithTimeout now respects caller timeout
- macOS ForceCloseApp: use force-terminate instead of graceful osascript quit
- Add Windows ForceCloseApp support via taskkill
- Terminate orphaned processes when WaitForExit times out
- Add diagnostic logging for iOS null UDID
- Wrap GetText/GetAttribute/GetRect/IsSelected in RunWithTimeout
- Wrap App.AppState and SaveUIDiagnosticInfo in timeouts
- Add regression tests for infinite layout cycle (Issue32586, Issue33595)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- iOS 18.x: prefer iPhone Xs (matches CI UITest.cs default)
- iOS 26.x: prefer iPhone 11 Pro (matches CI visual test requirement)
- Fallback to newer Pro models if preferred device unavailable

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Some tests (e.g., RefreshView) leave the app briefly busy after the test
body completes. The 15s AppState timeout was triggering false positives.
Now retries once after a 5s delay before declaring the app frozen.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests using small timeouts (e.g., 2s) were failing because CapTimeout
was limiting inner RunWithTimeout calls to the caller's timeout. Each
individual Appium command needs the full AppiumCommandTimeout to complete
normally. CapTimeout now only caps when the caller's total timeout
exceeds AppiumCommandTimeout, preventing multiple wasteful 45s iterations
for frozen apps while letting short-timeout tests work as before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PureWeen and others added 17 commits February 19, 2026 14:21
…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>
Remove MaxSafeAreaInvalidationsPerEvent static rate limiter and replace
with a proper root-cause fix for animation-driven safe area oscillation.

The rate limiter was a blunt instrument that capped total safe area
invalidations and only reset on keyboard/window events, which could
permanently block legitimate safe area changes.

The new approach uses two complementary mechanisms:

1. SafeAreaInsetsDidChange: Compare Window's safe area insets instead of
   per-view insets. Per-view SafeAreaInsets change whenever a view moves
   (including during TranslateToAsync animations), but Window insets
   only change for genuine system events (keyboard, rotation, status
   bar). This prevents animation-driven measurement oscillation.

2. InvalidateMeasure: When a propagating invalidation arrives and the
   global safe area generation has advanced, re-evaluate safe area.
   This ensures descendants pick up changes when a parent modifies
   SafeAreaEdges at runtime, since the parent's safe area path
   increments the generation counter.

Together these eliminate the infinite layout cycle in Issue33934 while
preserving runtime SafeAreaEdges changes (Issue32586) and all existing
safe area behavior.

Fixes #33934

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
Add robust checks for external process execution and improve timeout/error handling.

- AppiumLifecycleActions: verify process is non-null, WaitForExit succeeded, and ExitCode == 0 for xcrun/adb/osascript calls; log failures and return FailedEmptyResponse instead of assuming success.
- HelperExtensions: attach a continuation to orphaned Appium tasks (OnlyOnFaulted) to observe and log future exceptions, preventing unobserved task exceptions.
- UITestBase: explicitly rethrow TimeoutException from AppState probe so unresponsive-app timeouts bubble to the outer handler, and simplify the outer TimeoutException catch to handle timeout cases consistently.

These changes prevent false-success when command-line helpers fail, surface background task faults, and ensure unresponsive app timeouts are handled reliably.
- Fix Wait() timeout composition: inner RunWithTimeout now respects caller timeout
- macOS ForceCloseApp: use force-terminate instead of graceful osascript quit
- Add Windows ForceCloseApp support via taskkill
- Terminate orphaned processes when WaitForExit times out
- Add diagnostic logging for iOS null UDID
- Wrap GetText/GetAttribute/GetRect/IsSelected in RunWithTimeout
- Wrap App.AppState and SaveUIDiagnosticInfo in timeouts
- Add regression tests for infinite layout cycle (Issue32586, Issue33595)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests using small timeouts (e.g., 2s) were failing because CapTimeout
was limiting inner RunWithTimeout calls to the caller's timeout. Each
individual Appium command needs the full AppiumCommandTimeout to complete
normally. CapTimeout now only caps when the caller's total timeout
exceeds AppiumCommandTimeout, preventing multiple wasteful 45s iterations
for frozen apps while letting short-timeout tests work as before.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 2s timeout in VerifyCarouselViewBindingAndRendering was never honestly
enforced before — it silently got 45s. With CapTimeout now enforcing it,
the test fails. Fix the test to use an honest 45s timeout instead of
weakening CapTimeout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…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>
Remove MaxSafeAreaInvalidationsPerEvent static rate limiter and replace
with a proper root-cause fix for animation-driven safe area oscillation.

The rate limiter was a blunt instrument that capped total safe area
invalidations and only reset on keyboard/window events, which could
permanently block legitimate safe area changes.

The new approach uses two complementary mechanisms:

1. SafeAreaInsetsDidChange: Compare Window's safe area insets instead of
   per-view insets. Per-view SafeAreaInsets change whenever a view moves
   (including during TranslateToAsync animations), but Window insets
   only change for genuine system events (keyboard, rotation, status
   bar). This prevents animation-driven measurement oscillation.

2. InvalidateMeasure: When a propagating invalidation arrives and the
   global safe area generation has advanced, re-evaluate safe area.
   This ensures descendants pick up changes when a parent modifies
   SafeAreaEdges at runtime, since the parent's safe area path
   increments the generation counter.

Together these eliminate the infinite layout cycle in Issue33934 while
preserving runtime SafeAreaEdges changes (Issue32586) and all existing
safe area behavior.

Fixes #33934

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
Sort matching runtimes descending so iOS-18-5 is preferred over
iOS-18-3 when both have an iPhone Xs available.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…te-layout-fix

# Conflicts:
#	src/TestUtils/src/UITest.Appium/Actions/AppiumLifecycleActions.cs
#	src/TestUtils/src/UITest.Appium/HelperExtensions.cs
#	src/TestUtils/src/UITest.NUnit/UITestBase.cs
Replace the static global s_safeAreaGeneration counter with a per-view
_hasInvalidatedAncestorsForSafeArea boolean flag. The global counter
was shared across all MauiView instances which caused cross-window and
cross-hierarchy interference in multi-window scenarios (iPad split
view, multiple independent view hierarchies).

The per-view flag:
- Set in LayoutSubviews when a view invalidates ancestors from the
  safe area path, preventing re-entry (breaks parent/child cycles)
- Cleared on every genuine safe area event: window insets change,
  keyboard show/hide, MovedToWindow, direct InvalidateMeasure
  iterates direct child MauiViews to set their _safeAreaInvalidated
  and clear their flags, ensuring descendants re-evaluate

This gives complete multi-window isolation with no global state while
preserving all existing safe area behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dedicated InvalidateDescendantSafeAreas() method called only from
MapSafeAreaEdges. This avoids O(N) child iterations on every property
change (BackgroundColor, IsVisible, etc.) — the iteration now only runs
when SafeAreaEdges actually changes.

The recursive descent also walks through non-MauiView intermediaries
(e.g., WrapperView) to reach grandchildren that the previous
direct-children-only loop would miss.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the previous 3-layer approach (Window-level guard + isPropagating
guard + cycle-break flag + MapSafeAreaEdges descendant propagation) with a
single surgical fix: round _safeArea to the nearest device pixel when
storing in ValidateSafeArea.

This fixes the root cause — SafeAreaPadding uses exact double equality,
but per-view SafeAreaInsets oscillate by sub-pixel amounts during
TranslateToAsync animations. Rounding absorbs the noise so the comparison
returns true, preventing InvalidateAncestorsMeasures from firing and
breaking the infinite layout cycle.

Also stabilizes constraints fed to CrossPlatformMeasure, preventing
per-frame jitter from floating-point noise in _safeArea values.

Fixes #33934

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tScaleFactor

- Use ContentScaleFactor instead of Window.Screen.Scale for safer scale access
- Apply rounding fix to MauiScrollView to prevent latent cycle
- Remove Task.Delay from Issue32586 test for reliability

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

/azp run

@azure-pipelines
Copy link

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
Member Author

Alright @Tamilarasan-Paranthaman @sheiksyedm

Let me know what you think of this fix

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

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 3 pipeline(s).

PureWeen and others added 2 commits February 20, 2026 07:02
Issue32586:
- Add VerifyFooterPositionRespectsSafeArea: verifies footer extends into
  bottom safe area when SafeAreaEdges=None (tester reported footer always
  rendered above safe area regardless of setting)
- Rename VerifyLayoutWithTranslateToAsync -> VerifyFooterAnimationCompletes
  with clearer assertion messages
- Add BottomMarker and MainGrid AutomationId to HostApp for position checks

Issue33934:
- Move from Navigation to SafeAreaEdges category (correct categorization)
- Remove async Task.Delay pattern, use synchronous WaitForElement with
  clear timeout (15s) that directly proves whether animation loop exits
- Improve assertion messages to describe the infinite loop behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous test compared footer positions relative to each other (>=),
which passed even when both positions were wrong (both above safe area).
Now asserts footer reaches within 20pt of screen bottom when SafeAreaEdges=None.

Confirmed regression: footer is 34pt short (exact safe area inset) on iPhone Xs 18.5.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen
Copy link
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.

PureWeen and others added 3 commits February 20, 2026 10:53
- Add [Order(1/2/3)] to ensure deterministic test execution
- Add state reset at start of VerifyRuntimeSafeAreaEdgesChange to
  handle SafeAreaEdges state left by previous tests
- Fix Step 5 expected text to 'Footer is now hidden' (toggle hides
  if already shown)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…oise

Replace pixel-rounding approach with Window.SafeAreaInsets comparison.
During animations, a view's own SafeAreaInsets fluctuate with sub-pixel
noise, but Window.SafeAreaInsets remain stable (device-level insets like
status bar and home indicator). By comparing at the Window level,
SafeAreaInsetsDidChange skips animation-induced noise while still
detecting genuine safe area changes (rotation, keyboard, status bar).

Changes:
- MauiView.cs: Add _lastWindowSafeAreaInsets field, filter in
  SafeAreaInsetsDidChange(), reset in MovedToWindow()
- MauiScrollView.cs: Same Window-level filter, fix pre-existing bug
  where _safeAreaInvalidated was set to true instead of false in
  ValidateSafeArea() (flag was never being cleared after validation)

Fixes #32586, Fixes #33934

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Edges toggling

- VerifyRotationDuringAnimationPreservesSafeArea: validates genuine
  Window.SafeAreaInsets changes from rotation still propagate through
  the SafeAreaInsetsDidChange guard during active TranslateToAsync
- VerifyRapidSafeAreaToggleCycling: 3x rapid None/Container cycling
  validates the _safeAreaInvalidated bug fix doesn't break re-validation

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

Projects

None yet

3 participants