[iOS][New Arch] Legacy interop unmounts children by index against a drifted order — bulk keyed swaps detach the wrong subviews (crash + ghost views)
还没有人认领这个 Issue。
- 主要语言
- C++
- 星标
- 127k
- 派生
- 25.3k
- 平均合并
- 1 天 23 小时
- 30 天内合并 PR
- 4
描述
Description
On the New Architecture, RCTLegacyViewManagerInteropComponentView unmounts a legacy component's children by position, not by the child the mutation names:
// React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
{
if (_adapter && index < _adapter.paperView.reactSubviews.count) {
[_adapter.paperView removeReactSubview:_adapter.paperView.reactSubviews[index]];
} else {
[_viewsToBeUnmounted addObject:childComponentView];
}
}
During bulk keyed replacement of a legacy parent's children, the index model behind these remove instructions drifts from the paper view's actual reactSubviews order, so reactSubviews[index] resolves to the wrong subview. Depending on how far the drift goes, the result is either:
- an
NSRangeExceptioncrash when a later insert lands beyond the (over-shrunk) array — e.g.-[__NSArrayM insertObject:atIndex:]: index 11 beyond bounds [0 .. 9]inside the legacy view'sinsertReactSubview:atIndex:, or - silent wrong-victim unmounts: children React kept get detached, and children React removed stay alive as ghosts.
The deferred branch has a second defect: it queues the interop wrapper (childComponentView) and later passes it straight to removeReactSubview:. A legacy parent tracks the unwrapped paper view it originally received, so removal by wrapper identity is a silent no-op (this is the failure mode described in react-native-maps #5080).
Both defects are still present on main as of this report.
Evidence (native trace, run twice with identical results)
Instrumented insertReactSubview: / removeReactSubview: of AIRGoogleMap (react-native-maps 1.20.1, a legacy interop component) with pointer-level NSLogs, in an app whose map renders one keyed <Marker> list plus two trailing conditional <Marker> siblings (a device dot and a vehicle pin).
Scenario: the keyed list goes 4 → 83 → 4 (a provider filter applied, cleared, re-applied).
- Mount (6 children): markers arrive unwrapped, inserts at
idx == count, all consistent. - Grow 4 → 83: 79 inserts arrive at
idx6..84 — i.e. appended after the two trailing siblings, although JSX order places the keyed list before them. The mutation index model and the JSX child order have already diverged; nothing is visually wrong yet. - Shrink 83 → 4: 79 remove instructions arrive. Six of them resolve to the wrong views:
- all 6 originally-mounted markers (4 kept keyed children + both trailing siblings) get removed — React never unmounted them;
- 6 markers that React did unmount never receive a remove, and stay attached with their Fabric child (the icon content) stripped: invisible ghosts.
Trace tallies for step 3: map removes: 79 (count correct, victims wrong), mount-time markers removed: 6/6, ghosts never removed: 6, with zero out-of-bounds inserts. On the unpatched library this same scenario also produced the NSRangeException crash above.
Downstream issue reports that match this defect: react-native-maps #5080, #5217, #5345.
Suggested fix
Remove the child the mutation names, by identity, unwrapping interop wrappers — the paper-era semantic:
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
{
UIView *target = childComponentView;
if ([childComponentView isKindOfClass:[RCTLegacyViewManagerInteropComponentView class]]) {
UIView *content = ((RCTLegacyViewManagerInteropComponentView *)childComponentView).contentView;
if (content) {
target = content;
}
}
if (_adapter) {
[_adapter.paperView removeReactSubview:target];
} else {
[_viewsToBeUnmounted addObject:childComponentView];
}
}
…and the same unwrapping in the _viewsToBeUnmounted replay inside finalizeUpdates:. Identity removal is order-insensitive, so it stays correct however the index model drifts.
Caveat: we could not runtime-verify this patch in our app because Expo SDK 54 links React Native core as a prebuilt XCFramework (RCT_USE_PREBUILT_RNCORE), so the source edit never compiles. The analysis above is from reading the shipped source plus the trace. We worked around the bug at the app level by keying all markers to a per-result-set generation so every swap is a full remount — full replacement never mis-targets, which corroborates that partial swaps are the trigger.
Steps to reproduce
- New Architecture iOS app with a legacy interop component that intercepts children (react-native-maps
MapViewis the readily available one). - Render a keyed list of ~dozens of
<Marker>children followed by two conditionally-rendered<Marker>siblings. - Replace the keyed list with a much larger set (keep a few keys), then replace it back with the small set in a later commit.
- Observe: markers that should remain (including the trailing siblings) disappear from the map; markers that should be gone remain mounted with their content stripped. With enough churn,
NSRangeExceptionin the legacy view'sinsertReactSubview:atIndex:.
React Native Version
0.81.5 (defect verified present in main source at time of filing)
Affected Platforms
Runtime - iOS (New Architecture, legacy view manager interop)
Environment
- Expo SDK 54,
newArchEnabled: true, Hermes - react-native-maps 1.20.1 (Google provider) as the legacy interop component
- Reproduced on the iOS 26.3 simulator (iPhone 17 Pro)
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
调研方向
从 React/Fabric/Mounting/ComponentViews/LegacyViewManagerInterop/RCTLegacyViewManagerInteropComponentView.mm 开始,重点关注 unmountChildComponentView:index: 以及 finalizeUpdates: 中对 _viewsToBeUnmounted 的重放。使用 legacy interop 组件复现带 key 列表的交换,然后验证移除操作针对的是指定的 child,并确认不再出现崩溃、错误目标分离和幽灵 view。
由索引模型根据 Issue 内容生成。
评估
- 技术栈
- cpp, ios, objective-c, react-native
- 领域
- frontend, mobile-dev
- Issue 类型
- 缺陷
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 活跃度
- 活跃
- 描述清晰度
- 描述清楚
- 新手友好度
- 52/100