Skip to content

[Android][Fabric] Fatal "Cannot remove child at index" — focus re-request inside ViewGroup.removeViewInternal throws "descendant of this view" during Fabric removal (0.86.0) #57862

Open

Description

@chengjwk

Description

With an accessibility service enabled, removing a focused view during a Fabric mount batch reliably (if intermittently) kills the app with:

java.lang.IllegalStateException: Cannot remove child at index N from parent ViewGroup [tag],
only M children in parent. Warning: childCount may be incorrect!
  at com.facebook.react.fabric.mounting.SurfaceMountingManager.removeViewAt(SurfaceMountingManager.kt:525)
Caused by: java.lang.IllegalArgumentException: parameter must be a descendant of this view
  at android.view.ViewGroup.offsetRectBetweenParentAndChild(ViewGroup.java:6478)

ReactHostImpl.handleHostException then calls destroy() unconditionally, so this is fatal rather than recoverable.

Root cause

The IllegalArgumentException is not thrown by the removal itself — it comes from the accessibility event that the focus re-request fires inside removeViewInternal. Captured on device, read bottom-up:

MountItemDispatcher.dispatchMountItems / IntBufferBatchMountItem.execute
SurfaceMountingManager.removeViewAt(SurfaceMountingManager.kt:504)
ReactClippingViewManager.removeViewAt(ReactClippingViewManager.kt:68)
android.view.ViewGroup.removeViewAt(ViewGroup.java:5702)
android.view.ViewGroup.removeViewInternal(ViewGroup.java:5775)
android.view.View.rootViewRequestFocus(View.java:8827)
android.view.View.requestFocus -> ViewGroup.onRequestFocusInDescendants (recursion)
android.view.View.handleFocusGainInternal(View.java:8591)
android.view.View.onFocusChanged(View.java:8953)
android.view.View.sendAccessibilityEvent(View.java:9178)
android.view.ViewGroup.dispatchPopulateAccessibilityEventInternal(ViewGroup.java:3700)   (~10 deep)
android.view.ViewGroup$ChildListForAccessibility.init(ViewGroup.java:9334)
android.view.ViewGroup$ViewLocationHolder.init(ViewGroup.java:9520)
android.view.ViewGroup.offsetDescendantRectToMyCoords(ViewGroup.java:6407)
android.view.ViewGroup.offsetRectBetweenParentAndChild(ViewGroup.java:6478)   <- throws

So: the removed child held focus → removeViewInternal calls rootViewRequestFocus() → the focus gain fires an accessibility event → ChildListForAccessibility sorts children via offsetDescendantRectToMyCoords while the removed child is still mid-detach → IllegalArgumentException.

Because removeFromArray() has already run at that point, the removal has actually succeeded and the view tree is consistent. But the exception propagates out of ViewGroup.removeViewAt, and SurfaceMountingManager.removeViewAt's catch (e: RuntimeException) converts it into the fatal IllegalStateException quoted above — reporting a childCount inconsistency that isn't the real problem.

Why #56182 doesn't cover this

#56182 added a soft-catch for exactly this IllegalArgumentException — but only around addChildrenForAccessibility, i.e. the accessibility build path. main still has nothing on the removal path, and ReactViewGroup does not override any of removeViewAt / removeViews / removeViewsInLayout / removeAllViewsInLayout.

Note this is a different bug from #57800, which is the Fabric differ emitting a Remove against a wrong parent tag and throws earlier in the same method (SurfaceMountingManager.kt:444, "Unable to remove a view from a view that is not a ViewGroup", no Caused by). This one always carries the descendant of this view cause and requires an active accessibility service; that one requires neither.

Suggested fix

Mirror the #56182 pattern onto the removal path — wrap the super calls in ReactViewGroup's removal methods and soft-catch an IllegalArgumentException whose message contains descendant of this view. Since the child is already detached when it throws, swallowing leaves the tree consistent.

We currently ship precisely that as a build-time ASM transform (RN core is a prebuilt AAR for us, so a source patch would need react.internal.buildFromSource). With it in place the throw is caught, the app survives, and no Cannot remove child follows — verified on device with the stack above.

Steps to reproduce

No deterministic reproducer — it's a race between a Fabric removal and an accessibility service's tree traversal, and we could not force it with scripted input (adb shell input) even across ~20 list remounts and ~30 fling gestures. It surfaces readily in ordinary manual use. Conditions that reproduce it for us:

  1. Android 16 or 17 device with at least one accessibility service enabled (no TalkBack needed — a password manager's autofill service is enough).
  2. A long virtualized list whose rows contain heavy focusable native views (video players, WebViews), with removeClippedSubviews enabled so that recycling produces frequent native view removals.
  3. Scroll the list, or remount it (e.g. by changing its key).
  4. Watch for Cannot remove child at index with a Caused by: parameter must be a descendant of this view.

Two notes for anyone trying to reproduce: the removed view must hold focus for rootViewRequestFocus() to run, and the default logcat ring buffer rotates too fast under WebView logging to catch it after the fact (adb logcat -G 32M helps).

React Native Version

0.86.0

Affected Platforms

Runtime - Android

Areas

Fabric - The New Renderer

Output of npx @react-native-community/cli info

React Native: 0.86.0 (new architecture, bridgeless)
React: 19.2.3
Expo: 57.0.8
Hermes: bundled
Devices observed crashing: Android 16, and Android 17 (Pixel 9 Pro, CP2A.260805.005)
AGP: 8.12.0, Gradle: 9.3.1
Accessibility services active during repro: a password manager's autofill service, plus a
third-party status-bar utility (neither is TalkBack; either alone is sufficient)

— drafted by Claude (AI)

Activity

  1. react-native-bot commented on Aug 7, 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. chengjwk commented on Aug 14, 2026

    @chengjwk
    Author

    Following up with the full mechanism, plus the precondition that I think explains why this is hard to reproduce — and a candidate Playground repro built from it.

    The IAE is thrown by the framework during a focus re-request, not by RN

    ViewGroup.removeViewInternal detaches the child before calling rootViewRequestFocus(). If the removed child (or a descendant) held input focus, that focus re-request fires an accessibility event whose tree walk sorts children via offsetDescendantRectToMyCoords — while the just-detached child is still mid-removal:

    ReactClippingViewManager.removeViewAt        (RN — clipping-disabled branch)
      ViewGroup.removeViewAt → ViewGroup.removeViewInternal
        View.rootViewRequestFocus → requestFocus → onRequestFocusInDescendants
          → handleFocusGainInternal → onFocusChanged → sendAccessibilityEvent
            → dispatchPopulateAccessibilityEventInternal   (deep recursion)
              → ViewGroup$ChildListForAccessibility.obtain
                → ViewGroup$ViewLocationHolder.init
                  → offsetDescendantRectToMyCoords
                    → IllegalArgumentException: parameter must be a descendant of this view
    

    Why it surfaces as a fatal IllegalStateException

    The throw is synchronous inside the Fabric mount item, so it propagates into SurfaceMountingManager.removeViewAt's catch (e: RuntimeException) (v0.86.0, SurfaceMountingManager.kt:505) and is rethrown as IllegalStateException("Cannot remove child at index … only N children in parent") (:526).

    Three notes that may help triaging other reports of this ISE:

    • The ISE message identifies nothing. That catch wraps any RuntimeException from the removal, so the message is the same regardless of root cause. The Caused by: is the only discriminator.
    • only 0 children is expected here, not evidence against this diagnosis. removeViewInternal has already detached the child (count → 0) by the time the IAE fires, so RN's fallback count legitimately reports 0.
    • The cause is not in the AndroidRuntime FATAL block. It crosses JNI as com.facebook.jni.CppException: Exception in HostFunction, which flattens it; the Caused by: is logged separately by MountItemDispatcher. This is probably why many reports of this ISE arrive with no cause attached.

    Why #56182 doesn't cover this

    #56182 added a try/catch for this exact IAE, but only on addChildrenForAccessibility. I checked the source on both tags: v0.86.0 (ReactViewGroup.kt:1003, message check at :1072) and 0.87-stable (:989 / :1058). In both, that is still the only guarded method — there is no removeView* / removeViewsInLayout override. So the removal path is unguarded in 0.87.0 as well.

    Counterfactual evidence that the removal path is the right level to guard

    We inject an override of ReactViewGroup.removeViewAt via AGP bytecode instrumentation (same mechanism as the transform in #57800), wrapping only the super.removeViewAt(index) call in a catch for IllegalArgumentException whose message contains descendant of this view, and rethrowing anything else.

    With it in place, the IAE is intercepted one frame below SurfaceMountingManager's catch: the fatal ISE never occurs, the process survives, and the view hierarchy is intact afterwards (no missing or corrupted views from the swallowed removal). Without it, the same interaction is fatal. Mirroring #56182 onto the removal methods should be sufficient.

    Candidate reproducer — and the precondition I think was missing

    View.sendAccessibilityEvent is a no-op unless an accessibility service is running. With no a11y service enabled, this bug cannot fire at all, which I suspect is why it doesn't show up in ordinary testing. Preconditions I believe are necessary:

    1. An accessibility service enabled (e.g. TalkBack)
    2. The view being removed, or a descendant, holds input focus at removal time
    3. The removal dispatched through Fabric's mount path
    4. Removal via ViewGroup.removeViewAt — i.e. removeClippedSubviews disabled; the clipping branch takes removeViewWithSubviewClippingEnabled instead (ReactClippingViewManager.kt:58-65)

    Playground sketch:

    const [show, setShow] = React.useState(true);
    return (
      <View>
        <Button title="unmount" onPress={() => setShow(false)} />
        {show ? (
          <View>
            <TextInput autoFocus />
          </View>
        ) : null}
      </View>
    );

    With TalkBack enabled, focus the TextInput, then unmount the subtree.

    To be clear about the status: this snippet is untested — I'm posting the mechanism and the a11y-service precondition now in case that is enough for someone to confirm faster than I can. In our app the crash reproduces readily under real manual interaction but not under scripted adb shell input, so the timing seems to depend on genuine focus/a11y-event interleaving. If I can make it deterministic I'll open a Playground PR as the formal reproducer.

    Environment

    • React Native 0.86.0, New Architecture (Fabric + bridgeless), Hermes
    • Pixel 9 Pro, Android 17
    • Verified still unguarded in source at 0.87-stable / 0.87.0

    — drafted by Claude (AI)

  3. chengjwk commented on Aug 14, 2026

    @chengjwk
    Author

    Update: the candidate reproducer above does not fire. Built it as a standalone RN 0.86.0 app (Fabric, bridgeless, Hermes, release build; Pixel 9 Pro / Android 17) and drove ~800 timer-driven unmounts of a focused subtree, sweeping:

    • focused TextInput inside the removed subtree, with a focusable sibling surviving outside it
    • a sibling-rich parent (15 accessible children, removal at a middle index) so the ChildListForAccessibility → ViewLocationHolder sibling sort is actually reachable
    • removals scheduled through reanimated's exit-animation path (remount before the exit finishes, so the withheld Remove replays against a changed hierarchy)
    • concurrent externally-forced a11y tree traversals (uiautomator dump in a loop) with accessibility services enabled

    Zero IAEs, zero fatals. So the preconditions in my previous comment are necessary but evidently not sufficient. One caveat: TalkBack itself was not running — the enabled services subscribe only to TYPE_WINDOW_STATE_CHANGED. But in the app where this crashes readily, guard hits occur with exactly those services enabled, so a broader-subscription service is not required there.

    The clearest structural difference in the reproducing app: the crashing parent's ancestor chain runs through fragment-backed screen containers (react-native-screens: ScreenContentWrapper → Screen → ScreenStack), with removals arriving around navigation transitions. Possibly relevant to that: ScreenStackViewManager.getChildCount returns the fragment count while SurfaceMountingManager's fallback scan uses the raw childCount, so the two can disagree during teardown.

    Next step on my side: instrumenting the guarded build to dump the exact hierarchy (parent class, child count/classes, ancestor chain, focus holder) at the instant the IAE is caught, then minimizing from a known-crashing configuration instead of building up from a blank app. Will report back.

    — drafted by Claude (AI)

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: AttentionIssues where the author has responded to feedback.Needs: 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