react / react/react-native

iOS Fabric: prepareForRecycle does not reset contentInset, leaking the keyboard-derived inset into the next recycled ScrollView

Đang mở Phù hợp với người mới
#57,988 2 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Needs: Author Feedback Needs: Repro
Ngôn ngữ chính
C++
Star
127k
Fork
25.3k
Merge trung bình
1 ngày 23 giờ
Pull request đã merge (30 ngày)
4

Mô tả

Description

RCTScrollViewComponentView.prepareForRecycle does not reset contentInset, so a scroll view that was given a keyboard-derived contentInset.bottom by _keyboardWillChangeFrame: carries that inset into the next screen that reuses the recycled view.

The result is a scroll surface with a large phantom bottom inset: real, non-rubber-band, scrollable blank space below the content that does not spring back. It appears on screens that have nothing to do with keyboards, is healed only by relaunching the app, and is invisible to any inspection of the affected screen's own code.

There is a second contributing factor: _keyboardWillChangeFrame: trusts a keyboard frame whose origin.y is 0, which iOS publishes while a modal is being dismissed with the keyboard up.

Mechanism

React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm (0.81.5):

1. The inset is computed from the keyboard frame's top edge (~L190-200):

CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
CGFloat scrollViewLowerY = isInverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;

UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
CGFloat inset = MAX(scrollViewLowerY - keyboardEndFrame.origin.y, 0);
...
newEdgeInsets.bottom = MAX(inset, props.contentInset.bottom);

If keyboardEndFrame.origin.y == 0, inset becomes scrollViewLowerY — the scroll view's own lower edge in window coordinates. There is an existing special case for a degenerate keyboard frame just below (UIAccessibilityPrefersCrossFadeTransitions() + size.height == 0), but it only covers that accessibility setting and only the zero-height variant.

2. prepareForRecycle never restores the inset (~L616):

- (void)prepareForRecycle
{
  [super prepareForRecycle];
  _state.reset();

  const auto &props = static_cast<const ScrollViewProps &>(*_props);
  _scrollView.contentOffset = RCTCGPointFromPoint(props.contentOffset);
  _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
  _shouldUpdateContentInsetAdjustmentBehavior = YES;
  _isUserTriggeredScrolling = NO;
  CGRect oldFrame = self.frame;
  self.frame = CGRectZero;
  self.frame = oldFrame;
  _contentView = nil;
  _prevFirstVisibleFrame = CGRectZero;
  _firstVisibleView = nil;
}

contentOffset is restored from props on exactly this principle; contentInset and verticalScrollIndicatorInsets are not.

3. updateProps: cannot repair it on the next mount. oldScrollViewProps is derived from *_props (not from the oldProps argument), and prepareForRecycle does not reset _props, so on a recycled view the contentInset diff gate compares the previous props against the new ones — both default zero — and never fires. The stale UIKit value survives.

Reproduction shape

  1. A screen with automaticallyAdjustKeyboardInsets on a ScrollView/FlatList (in our case a chat thread presented modally, with the text input focused).
  2. Dismiss that modal while the keyboard is up. iOS publishes a keyboard frame with origin.y == 0; the list is stamped with contentInset.bottom == <its own lower Y>.
  3. Open any other screen containing any scroll view. It receives the recycled component view and inherits the inset.

Every subsequently mounted scroll surface is affected; surfaces mounted before the event are not; relaunching clears it (the recycle pool is gone).

Evidence

Measured on device (iPhone, 390x844 logical, Release build), by instrumenting onScroll and reading nativeEvent on three unrelated screens, two of which contain no keyboard-aware components at all:

Settings           content 1751  frame 753  contentInset top 0 bottom 700
AccountDetails     content 1276  frame 727  contentInset top 0 bottom 700
TransactionDetail  content 1153  frame 727  contentInset top 0 bottom 700

The inset was identical (700) across all three routes, across 7 separate visits and 605 scroll samples, while content heights and frames varied. 844 − 700 = 144, which is exactly the height of the chat composer plus the home indicator — i.e. 700 is the chat list's own lower Y, minted on that screen and carried elsewhere.

Reachable scroll extent matched contentSize + contentInset.bottom − frame in every sample, confirming the extra travel is inset rather than content.

Suggested fix

In prepareForRecycle, restore the inset state from props alongside the existing contentOffset restore:

_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset);
_scrollView.verticalScrollIndicatorInsets = RCTUIEdgeInsetsFromEdgeInsets(props.scrollIndicatorInsets);

Optionally, also ignore a degenerate keyboard frame in _keyboardWillChangeFrame:, since origin.y is the reference for every value the method derives (both the inset and contentDiff):

if (keyboardEndFrame.origin.y <= 0) {
  return;
}

A docked keyboard's top edge is always strictly positive on iPhone.

Notes

Surveying the other Fabric component views in 0.81.5, RCTScrollViewComponentView appears to be the only one that mutates UIKit state from an NSNotificationCenter observer (i.e. outside the props lifecycle), which is why the omission only bites here. RCTVirtualViewComponentView explicitly resets self.hidden in its own prepareForRecycle, which suggests this is an oversight rather than intent.

Version

React Native 0.81.5, iOS, New Architecture (Fabric), Hermes.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu trong React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm, đọc prepareForRecycle và _keyboardWillChangeFrame: cùng với logic hiện có để đặt lại contentOffset và cập nhật inset. Tái hiện kịch bản ScrollView được tái sử dụng được mô tả trong issue, sau đó xác minh rằng một view không còn mang các inset nội dung hoặc chỉ báo bắt nguồn từ bàn phím sang mount tiếp theo.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
ios, objective-c, react-native
Lĩnh vực
mobile
Loại issue
Lỗi
Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Đặc tả rõ ràng
Mức phù hợp với người mới
78/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.