iOS Fabric: prepareForRecycle does not reset contentInset, leaking the keyboard-derived inset into the next recycled ScrollView
还没有人认领这个 Issue。
评估
- 难度
- 2/5
- 预计耗时
- 1-3 小时
- 新手友好度
- 78/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 技术栈
- ios, objective-c, react-native
- 领域
- mobile
调研方向
从 React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm 开始,阅读 prepareForRecycle 和 _keyboardWillChangeFrame:,同时查看现有的 contentOffset 重置和 inset 更新逻辑。复现 issue 中描述的回收 ScrollView 场景,然后验证 view 不会再将键盘导致的内容或指示器 inset 带入下一次 mount。
由索引模型根据 Issue 内容生成。
描述
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
- A screen with
automaticallyAdjustKeyboardInsetson aScrollView/FlatList(in our case a chat thread presented modally, with the text input focused). - Dismiss that modal while the keyboard is up. iOS publishes a keyboard frame with
origin.y == 0; the list is stamped withcontentInset.bottom == <its own lower Y>. - 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.
- 主要语言
- C++
- 星标
- 127k
- 派生
- 25.3k
- PR 合并指标
- 30 天内没有已合并 PR
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
react/react-native 的其他 Issue
-
Needs: Author Feedback Needs: Repro
难度 1/5 1 小时以内 新手友好度 92/100
react/react-native#58621 · 1 条评论 ·
-
Needs: Author Feedback Needs: Repro
难度 2/5 1-3 小时 新手友好度 85/100
react/react-native#58610 · 1 条评论 ·
-
Needs: Triage :mag:
难度 2/5 1-3 小时 新手友好度 82/100
react/react-native#58565 · 1 条评论 · 2 个 reaction ·
-
Needs: Author Feedback Needs: Repro
难度 2/5 1-3 小时 新手友好度 88/100
react/react-native#58555 · 4 条评论 · 1 个 reaction ·
-
Needs: Attention Needs: Repro
难度 2/5 1-3 小时 新手友好度 85/100
react/react-native#58526 · 2 条评论 ·
查看 react/react-native 的全部 Issue
相似的 Issue
-
难度 1/5 1 小时以内 新手友好度 90/100
AXERA-TECH/ax-llm#77 ·
-
难度 1/5 1 小时以内 新手友好度 90/100
games-on-whales/wolf#509 ·
-
难度 2/5 1-3 小时 新手友好度 74/100
-
bug-unconfirmed
难度 2/5 1-3 小时 新手友好度 76/100
-
难度 2/5 1-3 小时 新手友好度 74/100
NVIDIA/cuda-samples#453 ·