Skip to content

[iOS][New Arch] Legacy interop unmounts children by index against a drifted order — bulk keyed swaps detach the wrong subviews (crash + ghost views) #57872

Description

@rmprosser

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).

  1. Mount (6 children): markers arrive unwrapped, inserts at idx == count, all consistent.
  2. 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.
  3. 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

  1. New Architecture iOS app with a legacy interop component that intercepts children (react-native-maps MapView is the readily available one).
  2. Render a keyed list of ~dozens of <Marker> children followed by two conditionally-rendered <Marker> siblings.
  3. 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.
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions