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

Open

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)

Activity

  1. react-native-bot commented on Aug 10, 2026

    @react-native-bot
    Collaborator

    Warning

    Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:


    You can read more about about it on our website: How to report a bug.

  2. richstimson commented on Aug 24, 2026

    @richstimson

    Map markers, circles and overlays silently disappear on the New Architecture, SDK 54 (iOS)

    TL;DR — if you hit this via react-native-maps, see Start here immediately below: on
    bare RN, upgrade the library; on Expo SDK 54 you are pinned to the affected version and the
    ready-to-apply diff is the way out. Evidence, reproducer and verification follow after.

    Start here — if you are hitting this in react-native-maps (check your Expo SDK first)

    Stating this up front because for most people it is a better answer than
    patching React Native: react-native-maps ships real Fabric component views
    from 1.21.0
    (Apr 2025). From that version the library is no longer a legacy
    component under the New Architecture, so it never goes through
    RCTLegacyViewManagerInteropComponentView and this bug cannot reach it. We were
    on 1.20.1 (Nov 2024) — the last release before that landed.

    If you are on bare React Native, upgrading react-native-maps to 1.21.0+ is
    the fix. Stop here; you do not need the patch.

    If you are on Expo, the version is not yours to choose. Expo pins native
    module versions per SDK in bundledNativeModules.json, and SDK 54 pins
    react-native-maps to exactly 1.20.1
    — the one release that is vulnerable.
    expo install --check reports "Dependencies are up to date", because by Expo's
    definition it is. So on SDK 54 you are pinned to the affected version by the
    SDK itself, and the RN patch below is the practical way out.

    I did try upgrading anyway, and would not recommend it: 1.21.0+ splits its
    codegen into a second podspec (react-native-maps-generated) that Expo's
    autolinking does not register, so pod install fails with "Unable to find a
    specification for react-native-maps-generated"
    . It only proceeds after
    hand-editing the Podfile, which trades a small, verified patch for an
    unsupported native integration. Reverted.

    SDK 55 looks like the clean exit. It pins react-native-maps 1.27.2
    (so do SDK 56 and 57) — well past 1.21.0, therefore Fabric-native, therefore
    immune, and it retires the patch entirely. I have verified the pin by unpacking
    expo@55.0.29's bundledNativeModules.json, but I have not yet run SDK 55,
    so treat "immune on 55" as a strong expectation rather than something I have
    personally confirmed on a device.

    So, roughly:

    you are on do this
    bare RN, maps ≤ 1.20.x upgrade react-native-maps to 1.21.0+
    Expo SDK 54 apply the RN patch below — the SDK pins you to 1.20.1
    Expo SDK 55+ you should already be on maps 1.27.2 and unaffected

    None of this makes the interop defect less real. It affects any legacy view
    manager that intercepts its children; react-native-maps is just the most visible
    casualty, and the one with a version-based escape route. Anyone else hitting it
    through a different legacy component has only the RN fix to wait for — which is
    why it is still worth fixing upstream.

    The diff, verified and ready to apply

    Posting this inline and free for anyone to take to a PR. No attribution
    needed; the diagnosis and the fix are @rmprosser's — this is only their
    suggested change written out and confirmed working.

    Against react-native@0.81.5,
    packages/react-native/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]];
    +  // Remove the child the mutation NAMES, by identity, rather than whatever
    +  // currently sits at `index`: Fabric's index model drifts from a legacy
    +  // parent's own reactSubviews order, so index-based removal detaches the
    +  // wrong subview. Identity removal is order-insensitive.
    +  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 deferred replay inside finalizeUpdates:,
    which otherwise hands a legacy parent an interop wrapper it never tracked:

       for (UIView *view in _viewsToBeUnmounted) {
    -    [_adapter.paperView removeReactSubview:view];
    +    UIView *unmountTarget = view;
    +    if ([view isKindOfClass:[RCTLegacyViewManagerInteropComponentView class]]) {
    +      UIView *content = ((RCTLegacyViewManagerInteropComponentView *)view).contentView;
    +      if (content) {
    +        unmountTarget = content;
    +      }
    +    }
    +    [_adapter.paperView removeReactSubview:unmountTarget];
       }

    Both hunks are needed: the first fixes the wrong-victim unmount, the second
    fixes the silent no-op on the deferred path.

    Searchable symptom terms, since this issue's title describes the mechanism
    rather than what people actually see:
    react-native-maps markers vanish,
    pins disappear, Circle/Polygon/Polyline not showing, overlays
    disappear after zoom or pan
    , markers gone after zooming out, map
    children never come back, works on the Old Architecture but breaks with
    newArchEnabled: true on Expo SDK 54 / RN 0.81.

    Independent confirmation of this bug, plus the runtime verification the report
    says it was blocked on, and the exact diff that fixes it — free to take as-is.

    I hit this on Expo SDK 54 (RN 0.81.5) with react-native-maps 1.20.1 and arrived
    at the same two defects and the same fix by reading the source, before finding
    this issue.

    Simple reproducer

    No crash and no bulk keyed swap required — the silent wrong-victim unmount reproduces with three children and one small camera change.

    The general shape: any with a short-lived child sandwiched between long-lived ones. "Short-lived" means React unmounts and remounts it as the region changes — typically because its key is derived from the region. The long-lived children just have to persist across that change.

    To be clear about provenance: the failure, the before/after verification and the patch result below are all from our production app, on a physical device. The snippet immediately below is a minimal illustration of that same child structure, written to show the shape in the fewest possible lines — I have not run this exact file standalone, so treat it as a description of the pattern rather than a turnkey repro.

    import React, { useState } from 'react';
    import MapView, { Marker, Circle } from 'react-native-maps';
    
    const START = {
      latitude: 50.6757, longitude: -1.2966,
      latitudeDelta: 1.117, longitudeDelta: 0.9,
    };
    
    export default function App() {
      const [region, setRegion] = useState(START);
    
      // Short-lived: the key changes with the zoom level, so React unmounts and
      // remounts this child on every region change. A clustering library produces
      // the same effect (cluster ids differ per zoom) but is not required.
      const zoomBucket = Math.round(region.latitudeDelta * 100);
    
      return (
        <MapView
          style={{ flex: 1 }}
          initialRegion={START}
          onRegionChangeComplete={setRegion}
        >
          {/* 1. long-lived, stable key */}
          <Marker key="stable-a" coordinate={{ latitude: 50.68, longitude: -1.30 }} />
    
          {/* 2. SHORT-LIVED — remounts on every region change */}
          <Marker key={`transient-${zoomBucket}`} coordinate={{ latitude: 50.70, longitude: -1.25 }} />
    
          {/* 3. long-lived */}
          <Circle center={START} radius={20000} strokeColor="#3399ff" />
        </MapView>
      );
    }
    
    

    Zoom out by any amount — 1.25x is enough, there is no magnitude threshold. The short-lived child is removed from the middle of the list, the mutation indices drift, and the removals land on stable-a and the instead. Those two detach; the short-lived child repaints correctly.

    They do not come back. Returning the camera to the byte-identical starting region does not restore them — they were genuinely removed from MapKit, which is what rules out any timing or paint explanation.

    We hit this via Supercluster (cluster ids change per zoom, so the cluster markers are the short-lived group), but nothing about clustering is required: viewport-filtered markers, or any region-derived list, produce the same drift.

    Key properties, all consistent with the index-drift mechanism:

    • No magnitude threshold. 1.25x is enough. Any re-cluster does it.
    • Permanent. Animating the camera back to the byte-identical starting
      region does not restore them — a real native removal, not a paint glitch.
    • Both overlay and annotation types die together, so it is not specific to
      AIRMapCircle or AIRMapMarker; it is the shared
      insertReactSubview:/removeReactSubview: path.
    • React never unmounted them. Our JS-side render log for the tracked
      markers keeps reporting the marker as rendered throughout the failure, which
      is what makes this so hard to debug from JS: the JS oracle says the marker is
      present while the screen shows nothing. (We instrumented the marker path
      specifically; the circle is a plain conditional render, but it disappears and
      returns in lockstep with the marker.)
    • Old Architecture is unaffected. Same app, same react-native-maps 1.20.1,
      newArchEnabled off (RN 0.79.6): no repro. Turning the New Architecture on
      is the only variable — which rules the map library out, since it is pinned
      identically on both.

    Runtime verification of the suggested fix

    The report notes it could not runtime-verify the patch because Expo SDK 54
    links RN core as a prebuilt XCFramework. **This project builds RN core from source, so the patch compiles and runs here.

    Worth being precise about why, because it is luck rather than configuration:
    our ios/Podfile is checked in and was generated before Expo introduced the
    prebuilt-core switch, so it has no RCT_USE_PREBUILT_RNCORE logic in it at all
    and has never been regenerated. A current SDK 54 template gets the prebuilt
    default.

    For anyone wanting to reproduce that setup: the switch itself is one line in
    ios/Podfile.properties.json —

    { "ios.buildReactNativeFromSource": "true" }

    — but be warned that flipping it on a clean SDK 54 app is not necessarily
    enough. I tried exactly that on a scratch app with identical versions
    (Expo 54, RN 0.81.5, react-native-maps 1.20.1) to build a standalone
    reproducer, and it fails to compile under Xcode 26:

    fmt/format-inl.h:59: error: call to consteval function
    'fmt::basic_format_string<...>' is not a constant expression
    

    RN 0.81's bundled fmt does not build with Xcode 26's clang, which is
    presumably part of why the prebuilt XCFramework exists in the first place. So
    "just build from source" is not a trivial suggestion on a current toolchain —
    the original report hitting the prebuilt wall is the normal case, not an
    unusual one.

    Applied exactly as suggested — identity-based removal with wrapper unwrapping
    in unmountChildComponentView:, plus the same unwrapping in the
    _viewsToBeUnmounted replay inside finalizeUpdates: — via patch-package:

    It fixes it. Verified on a physical iPhone 13 (iOS 26.3.1), screenshots
    rather than logs, against the same reproduction that failed before the patch:

    check before after
    zoom 1.25x marker + circle both gone both present
    zoom ladder to 6x, then back both permanently gone both present
    large geofence move (~250 miles, animateToRegion + Circle prop change) circle missing circle correct

    The second row is the one that matters most: before the patch, returning the
    camera to the byte-identical starting region never brought the children back,
    because they had been genuinely removed from MapKit. After it, they survive the
    whole ladder.

    For anyone matching this against their own screenshots: in the broken state the
    map still renders normally — base tiles, labels, and the short-lived clustered
    markers all repaint correctly at every zoom level. Only the long-lived children
    are missing, with no error, no warning, and no crash. That is what makes it read
    as "the marker just isn't there" rather than as a rendering fault.

    Both defects were load-bearing in this app: the first causes the wrong-victim
    unmount, and the second matters because the deferred branch passes the interop
    wrapper to removeReactSubview:, which a legacy parent never tracked.

    Note on the downstream reports

    All three downstream reports are in the react-native-maps repo, and all
    three are closed while the defect is still live on RN main:

    The symptom surfaces in a different repo from its cause, so it keeps getting
    closed as a maps problem. Worth linking this issue from those threads once it
    lands.

  3. react-native-bot commented on Sep 19, 2026

    @react-native-bot
    Collaborator

    This issue is waiting for author's feedback since 24 days. Please provide the requested feedback or this will be closed in 7 days.

  4. added
    StaleThere has been a lack of activity on this issue and it may be closed soon.
    and removed
    StaleThere has been a lack of activity on this issue and it may be closed soon.
    on Sep 19, 2026
  5. react-native-bot commented on Oct 13, 2026

    @react-native-bot
    Collaborator

    This issue is waiting for author's feedback since 24 days. Please provide the requested feedback or this will be closed in 7 days.

  6. added
    StaleThere has been a lack of activity on this issue and it may be closed soon.
    on Oct 13, 2026
  7. habovh commented on Oct 15, 2026

    @habovh
    Contributor

    This issue is likely going to be fixed by #59005, I believe it should not be closed until then.

  8. removed
    StaleThere has been a lack of activity on this issue and it may be closed soon.
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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