Skip to content

fix(ios): stop infinite loop in PageChildViewController.propagateSafe - #1237

Open
troZee wants to merge 2 commits into
mainfrom
fix/1232-safe-area-propagation
Open

troZee wants to merge 2 commits into
mainfrom
fix/1232-safe-area-propagation

Conversation

@troZee

@troZee troZee commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes the main-thread hang reported in #1232: PageChildViewController.propagateSafeArea() can loop forever on iOS, pinning a core at 100% until the watchdog kills the app (0x8BADF00D).

Root cause

additionalInsets(toReach:) derives the inset this controller already inherits as:

inherited = view.safeAreaInsets - additionalSafeAreaInsets

which assumes UIKit applied the previous write to additionalSafeAreaInsets. When a page is re-hosted by an iOS 18+ zoom transition (e.g. expo-router Link.AppleZoom), UIKit can stop applying additionalSafeAreaInsets for that page: view.safeAreaInsets stays fixed at 62 while the written value drifts by the same ~-36.9pt every pass.

Because of that, the > 0.5 "did it change?" check is always true, so each write re-enters viewSafeAreaInsetsDidChange -> propagateSafeArea -> another write, and the next layout pass starts it over again:

PageChildViewController.viewDidLayoutSubviews()          Extensions.swift
  PageChildViewController.propagateSafeArea()            (additionalSafeAreaInsets = insets)
    -[UIViewController _updateContentOverlayInsetsForSelfAndChildren]
      ... -[UIView _safeAreaInsetsDidChangeFromOldInsets:]
        PageChildViewController.viewSafeAreaInsetsDidChange()
          PageChildViewController.propagateSafeArea()    ... (nested forever)

Fix

propagateSafeArea now:

  1. Ignores its own nested callback via an isPropagatingSafeArea re-entrancy guard.
  2. Writes again only when something actually moved — the target safe area or the page's actual view.safeAreaInsets must differ from the ones recorded at the last write (lastSafeAreaWrite).

In the normal case the first write lands and later passes have nothing to change, so behaviour is unchanged. In the broken state it writes once and stops.

Test plan

Credit to @jakzaizzat for the diagnosis and the patch in #1232.

…Area

additionalInsets(toReach:) assumes UIKit applies the value written to
additionalSafeAreaInsets. A page re-hosted by an iOS 18+ zoom transition can
keep a fixed safeAreaInsets instead, so every layout pass subtracts the same
gap again, the > 0.5 change check never settles and the main thread loops
forever until the watchdog kills the app (0x8BADF00D).

Ignore our own nested viewSafeAreaInsetsDidChange callback and write again
only when the target or the page's actual safe area moved since the last
write. In the normal case behavior is unchanged; in the broken state the
loop writes once and stops.

Fixes #1232
Copilot AI balanced review requested due to automatic review settings November 6, 2026 21:38
@apex-sentinel-staging

apex-sentinel-staging Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Apex Sentinel

Verdict: approve

Since the last review: 0 new · 0 still open · 1 no longer raised

No findings.

6 findings below minor not shown (user_settings.min_severity).

Reviewed with this repository's own Sentinel configuration (d7d1c3b47dd7).

Feedback goes in the review comment threads, as a reply under the inline comment of a finding:

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if it is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

Comment @apex-sentinel-staging review on this conversation to run a full review.

Earlier reviews

Review of da70d53e · 06.11.2026 21:43 UTC

Apex Sentinel

Verdict: approve

1 finding(s): 1 minor.

🟡 Minor (1)

Where Finding
ios/Extensions.swift:91 iOS 18 zoom-transition safe-area loop (#​1232) fix ships without a repro or regression test

3 findings below minor not shown (user_settings.min_severity).

Reviewed with this repository's own Sentinel configuration (d7d1c3b47dd7).

@apex-sentinel-staging apex-sentinel-staging Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Apex Sentinel: 1 new finding on da70d53. The verdict and the full list are in the summary comment.

Comment thread ios/Extensions.swift
Comment on lines +91 to 123
///
/// `additionalInsets(toReach:)` assumes UIKit applies the value written
/// here. A page re-hosted by an iOS 18+ zoom transition can keep a fixed
/// `safeAreaInsets` instead, so every pass subtracts the same gap again and
/// the main thread loops forever (#1232). Ignore our own nested callback
/// and write again only when the target or the page's actual safe area
/// moved since the last write.
private func propagateSafeArea() {
let insets = additionalInsets(toReach: targetSafeAreaInsets())
if abs(additionalSafeAreaInsets.top - insets.top) > 0.5
|| abs(additionalSafeAreaInsets.left - insets.left) > 0.5
|| abs(additionalSafeAreaInsets.bottom - insets.bottom) > 0.5
|| abs(additionalSafeAreaInsets.right - insets.right) > 0.5 {
additionalSafeAreaInsets = insets
guard !isPropagatingSafeArea else {
return
}
let target = targetSafeAreaInsets()
let insets = additionalInsets(toReach: target)
if Self.insetsMatch(additionalSafeAreaInsets, insets) {
return
}
if let lastWrite = lastSafeAreaWrite,
Self.insetsMatch(lastWrite.target, target),
Self.insetsMatch(lastWrite.safeArea, view.safeAreaInsets) {
return
}
isPropagatingSafeArea = true
additionalSafeAreaInsets = insets
isPropagatingSafeArea = false
lastSafeAreaWrite = (target: target, safeArea: view.safeAreaInsets)
}

private static func insetsMatch(_ lhs: UIEdgeInsets, _ rhs: UIEdgeInsets) -> Bool {
abs(lhs.top - rhs.top) <= 0.5
&& abs(lhs.left - rhs.left) <= 0.5
&& abs(lhs.bottom - rhs.bottom) <= 0.5
&& abs(lhs.right - rhs.right) <= 0.5
}

@apex-sentinel-staging apex-sentinel-staging Bot Nov 6, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No longer raised on c5f772f.

🟡 Minor · iOS 18 zoom-transition safe-area loop (#​1232) fix ships without a repro or regression test

No example screen in example/src/gh-issues or e2e case in e2e/tests/issues/ covers this fix. Neighbouring commits 457a73c and its revert d7d1c3b show this code regressing silently, and the hung-main-thread failure mode would go undetected by the existing issue-1142-search-bar-inset.e2e.ts.

Suggested change

Add an example repro screen for #​1232 plus an e2e regression test that drives the iOS 18+ zoom-transition re-hosting and asserts the main thread stays responsive / the page's measured insets stay stable.

Details

persona: senior-developer

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The guard can suppress further corrections after a negative inset clamps the effective safe area to zero, preventing legitimate convergence to the target.

1 open issue
What changed in this PR

This PR addresses the iOS main-thread hang reported in #1232 by preventing repeated safe-area corrections when UIKit stops applying them after a zoom transition.

Changes:

  • Adds a re-entrancy guard to ignore callbacks triggered by safe-area writes.
  • Records the last target and actual safe-area insets to suppress unchanged retries.
  • Centralizes inset comparison and documents the failure scenario.
File Description
ios/​Extensions.swift Adds guards and inset tracking to prevent repeated safe-area writes in PageChildViewController.

Review effort: Balanced

Comment thread ios/Extensions.swift Resolved
@troZee

troZee commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

@apex-sentinel-staging review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

A critical inset-rebasing bug in ios/Extensions.swift repeatedly changes already-correct edges, bypassing the unchanged-sample guard and causing correction drift on every layout pass.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread ios/Extensions.swift
Comment on lines +133 to +148
/// `additionalSafeAreaInsets` with every clamped edge (negative correction,
/// 0 actual inset) set to 0, or nil when no edge is clamped.
private func additionalInsetsWithoutClampedEdges() -> UIEdgeInsets? {
func unclamped(_ additional: CGFloat, _ actual: CGFloat) -> CGFloat {
additional < -0.5 && actual <= 0.5 ? 0 : additional
}
let current = additionalSafeAreaInsets
let actual = view.safeAreaInsets
let rebased = UIEdgeInsets(
top: unclamped(current.top, actual.top),
left: unclamped(current.left, actual.left),
bottom: unclamped(current.bottom, actual.bottom),
right: unclamped(current.right, actual.right)
)
return rebased == current ? nil : rebased
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@troZee could you take a look?

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🟢 e2e: 2 flaky, 38 passed

⚠️ 2 flaky tests passed on a retry

⚠️ a setPage that interrupts another still lands on the later page
tests/issues/issue-1154-page-change-focus.e2e.ts:30

ASSERTION_FAILED at step 10 of 10: expect.toHaveText getByTestId("issue-1154-status"), after 10.1s

  • Expected: text /^Selected: 2 \| Focused: none \| Keyboard: hidden/
  • Observed: text "Selected: 1 | Focused: none | Keyboard: hidden" (1 match)
  • Screen: com.pagerviewexample / Issue #1154 Page Change Focus Repro

Evidence: log

⚠️ a setPage redirected once the focus has landed blurs it
tests/issues/issue-1154-page-change-focus.e2e.ts:30

ASSERTION_FAILED at step 9 of 9: expect.toHaveText getByTestId("issue-1154-status"), after 10.0s

  • Expected: text /^Selected: 2 \| Focused: none \| Keyboard: hidden/
  • Observed: text "Selected: 1 | Focused: none | Keyboard: hidden" (1 match)
  • Screen: com.pagerviewexample / Issue #1154 Page Change Focus Repro

Evidence: log

All 40 tests in 16 files
Test Time
⚠️ tests/issues/issue-1154-page-change-focus.e2e.ts · 2 flaky, 11 passed 4m 17s
🟢 leaving a page with setPage blurs its input 18.4s
🟢 setPage with an input focused lands on the target page 15.9s
🟢 a setPage two pages away lands on the far page 15.2s
🟢 a setPage repeated during the slide changes nothing 16.3s
🟢 leaving a page with a swipe blurs its input 15.6s
🟢 an input focused on the new page keeps focus 24.0s
🟢 a swipe keeps the focus the new page takes from onPageSelected 21.2s
🟢 a setPage that interrupts another lands on the page asked for last 22.3s
⚠️ a setPage that interrupts another still lands on the later page (1 failed attempt first) 22.9s
⚠️ a setPage redirected once the focus has landed blurs it (1 failed attempt first) 18.5s
🟢 two setPage calls that cancel out land on the first page 16.4s
🟢 right to left › leaving a page with a swipe blurs its input 33.9s
🟢 a page that grows with the keyboard still lands on the target page 16.3s
🟢 tests/basic-pager.e2e.ts · 3 passed 2m 10s
🟢 basic pager › in LTR › pages horizontally 51.2s
🟢 basic pager › in LTR › pages vertically 28.0s
🟢 basic pager › in RTL › pages horizontally with the reversed gesture 50.7s
🟢 tests/issues/issue-1050-back-gesture.e2e.ts · 8 passed 2m 23s
🟢 back gesture over the pager › in LTR › a back swipe on the first page pops the screen 15.1s
🟢 back gesture over the pager › in LTR › a back swipe on a later page goes to the previous page 13.7s
🟢 back gesture over the pager › in LTR › a forward swipe on the first page still goes to the next page 10.3s
🟢 back gesture over the pager › in RTL › a back swipe on the first page pops the screen 27.3s
🟢 back gesture over the pager › in RTL › a back swipe on a later page goes to the previous page 30.0s
🟢 back gesture over the pager › in RTL › a forward swipe on the first page still goes to the next page 25.6s
🟢 back gesture over a pager in a nested stack › a back swipe on the first page pops the outer stack 11.7s
🟢 back gesture over a pager in a nested stack › a forward swipe on the first page still goes to the next page 9.1s
🟢 tests/issues/issue-1083-modal-set-page.e2e.ts · 1 passed 17.9s
🟢 setPage called behind a native-stack modal lands on the requested page 17.9s
🟢 tests/issues/issue-1096-keyboard-shrink.e2e.ts · 1 passed 33.9s
🟢 keyboard avoidance does not shrink the page under the keyboard 33.9s
🟢 tests/issues/issue-1098-nested-pager.e2e.ts · 1 passed 17.5s
🟢 outer pager stays responsive after its page count changes around a nested pager 17.5s
🟢 tests/issues/issue-1142-search-bar-inset.e2e.ts · 1 passed 16.1s
🟢 FlatList inside the pager gets the search bar safe-area insets 16.1s
🟢 tests/issues/pr-1194-remount-initial-page.e2e.ts · 1 passed 19.0s
🟢 a pager remounted after its screen was covered starts on its own initialPage 19.0s
🟢 tests/keyboard-dismiss-mode.e2e.ts · 3 passed 40.1s
🟢 on-drag: dragging the pager blurs the input 14.6s
🟢 on-drag: setPage keeps the input focused 13.3s
🟢 none: dragging the pager keeps the input focused 12.3s
🟢 tests/material-top-bar.e2e.ts · 1 passed 20.8s
🟢 material top tabs survive a native-stack push and pop 20.8s
🟢 tests/native-driver-events.e2e.ts · 2 passed 38.3s
🟢 native driver events › setPage through the Animated ref drives the native value 22.0s
🟢 native driver events › a swipe drives the native value 16.3s
🟢 tests/nested-pager-view.e2e.ts · 1 passed 20.0s
🟢 nested horizontal and vertical pagers scroll independently 20.0s
🟢 tests/on-page-selected.e2e.ts · 1 passed 18.0s
🟢 onPageSelected fires an alert for every page change 18.0s
🟢 tests/scrollable-pager-view.e2e.ts · 1 passed 24.6s
🟢 pager inside a ScrollView keeps its page across vertical scrolls 24.6s
🟢 tests/smoke.e2e.ts · 1 passed 10.6s
🟢 horizontal pager pages with the panel buttons 10.6s
🟢 tests/tab-view-inside-scroll-view.e2e.ts · 1 passed 18.5s
🟢 TabView inside a ScrollView swipes tabs and scrolls the page 18.5s

e2e 0.21.3 · 14m 52s · ios · run artifacts

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants