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
NSRangeException crash 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's insertReactSubview: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
idx 6..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
MapView is 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,
NSRangeException in the legacy view's insertReactSubview: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)
Description
On the New Architecture,
RCTLegacyViewManagerInteropComponentViewunmounts a legacy component's children by position, not by the child the mutation names:During bulk keyed replacement of a legacy parent's children, the index model behind these remove instructions drifts from the paper view's actual
reactSubviewsorder, soreactSubviews[index]resolves to the wrong subview. Depending on how far the drift goes, the result is either: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:, orThe deferred branch has a second defect: it queues the interop wrapper (
childComponentView) and later passes it straight toremoveReactSubview:. 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
mainas of this report.Evidence (native trace, run twice with identical results)
Instrumented
insertReactSubview:/removeReactSubview:ofAIRGoogleMap(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).
idx == count, all consistent.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.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 theNSRangeExceptioncrash 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:
…and the same unwrapping in the
_viewsToBeUnmountedreplay insidefinalizeUpdates:. 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
MapViewis the readily available one).<Marker>children followed by two conditionally-rendered<Marker>siblings.NSRangeExceptionin the legacy view'sinsertReactSubview:atIndex:.React Native Version
0.81.5 (defect verified present in
mainsource at time of filing)Affected Platforms
Runtime - iOS (New Architecture, legacy view manager interop)
Environment
newArchEnabled: true, Hermes