Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 67 additions & 6 deletions ios/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class PageChildViewController: UIViewController {
NSSelectorFromString("_parentGestureRecognizerContainer")

var wrappedView: UIView?
private var isPropagatingSafeArea = false
private var lastSafeAreaWrite: (target: UIEdgeInsets, safeArea: UIEdgeInsets)?

override func viewDidLoad() {
super.viewDidLoad()
Expand Down Expand Up @@ -86,14 +88,73 @@ class PageChildViewController: UIViewController {
/// Do not walk to the nearest ancestor with a non-zero inset. That source
/// is in the ancestor's own coordinate space and can track scroll offset
/// (#1099).
///
/// `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.
///
/// UIKit clamps `safeAreaInsets` at 0, so an edge with a negative
/// correction and a 0 inset hides the inherited value, and an unchanged
/// sample there does not mean UIKit ignored the write. Clear those edges
/// first to measure what is inherited, then correct from that. Skip edges
/// whose target is 0: they are already correct, and clearing them on every
/// pass would bypass the unchanged-sample guard for a stuck edge.
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()
var insets = additionalInsets(toReach: target)
if Self.insetsMatch(additionalSafeAreaInsets, insets) {
return
}
if let unclamped = additionalInsetsWithoutClampedEdges(target: target) {
writeAdditionalSafeAreaInsets(unclamped)
insets = additionalInsets(toReach: target)
if Self.insetsMatch(additionalSafeAreaInsets, insets) {
return
}
} else if let lastWrite = lastSafeAreaWrite,
Self.insetsMatch(lastWrite.target, target),
Self.insetsMatch(lastWrite.safeArea, view.safeAreaInsets) {
return
}
writeAdditionalSafeAreaInsets(insets)
lastSafeAreaWrite = (target: target, safeArea: view.safeAreaInsets)
}

private func writeAdditionalSafeAreaInsets(_ insets: UIEdgeInsets) {
isPropagatingSafeArea = true
additionalSafeAreaInsets = insets
isPropagatingSafeArea = false
}

/// `additionalSafeAreaInsets` with every clamped edge (negative correction,
/// 0 actual inset, non-zero target) set to 0, or nil when no edge is
/// clamped.
private func additionalInsetsWithoutClampedEdges(target: UIEdgeInsets) -> UIEdgeInsets? {
func unclamped(_ additional: CGFloat, _ actual: CGFloat, _ target: CGFloat) -> CGFloat {
additional < -0.5 && actual <= 0.5 && target > 0.5 ? 0 : additional
}
let current = additionalSafeAreaInsets
let actual = view.safeAreaInsets
let rebased = UIEdgeInsets(
top: unclamped(current.top, actual.top, target.top),
left: unclamped(current.left, actual.left, target.left),
bottom: unclamped(current.bottom, actual.bottom, target.bottom),
right: unclamped(current.right, actual.right, target.right)
)
return rebased == current ? nil : rebased
}

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
}
Comment on lines +91 to 158

@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


private func additionalInsets(toReach target: UIEdgeInsets) -> UIEdgeInsets {
Expand Down
Loading