From 6a62c9e63065e96e605ae4aca02833e7a5217da5 Mon Sep 17 00:00:00 2001 From: heecheolman Date: Thu, 22 Oct 2026 23:39:32 +0900 Subject: [PATCH] Fix addViewAt crash when reparenting a view that is being animated out Summary: When a view is moved to a new parent (for example because an intermediate view is flattened or unflattened), Fabric emits a Remove from the previous parent followed by an Insert into the new one. If the view is being animated out of its previous parent at that moment (it is in that parent's transitioning views, e.g. via LayoutTransition or startViewTransition), ViewGroup removes it from its children but keeps its parent pointer until the transition ends. addViewAt already detects that the view still has a parent and calls removeView() on it, but removeView() looks the view up in the children array, finds nothing, and silently returns. The following addView() then throws "The specified child already has a parent", which is rethrown as "addViewAt: failed to insert view [...]" and crashes the app. If the view is still attached to the same parent after removeView(), end the view transition on that parent, which clears the parent pointer, so the view can be inserted into its new parent. Changelog: [Android] [Fixed] - Fix `addViewAt: failed to insert view` crash when a view is reparented while being animated out of its previous parent Test Plan: Added SurfaceMountingManagerTest.addViewAt_reparentsViewThatIsTransitioningOutOfPreviousParent, which starts a view transition on the previous parent, removes the child, and inserts it into another parent. Without this change it fails with the same exception seen in production; with it, it passes. ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.fabric.*' Co-Authored-By: Claude Opus 5.5 --- .../fabric/mounting/SurfaceMountingManager.kt | 6 ++++ .../fabric/SurfaceMountingManagerTest.kt | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt index 0c8c7bf05502..1d96859c0491 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt @@ -361,6 +361,12 @@ internal constructor( // View in the future. if (currParentView is ViewGroup) { currParentView.removeView(view) + // If the view is being animated out of its previous parent (e.g. by a LayoutTransition), + // ViewGroup has already dropped it from its children but keeps its parent pointer until + // the transition ends, so removeView above is a no-op. End the transition to detach it. + if (view.parent === currParentView) { + currParentView.endViewTransition(view) + } } erroneouslyReaddedReactTags.add(tag) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/SurfaceMountingManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/SurfaceMountingManagerTest.kt index f7372288b68f..c9eca933fdc6 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/SurfaceMountingManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/SurfaceMountingManagerTest.kt @@ -9,6 +9,7 @@ package com.facebook.react.fabric +import android.view.ViewGroup import com.facebook.react.ReactRootView import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.ReactTestHelper @@ -192,4 +193,32 @@ class SurfaceMountingManagerTest { smm.preallocateView("RCTView", 42, JavaOnlyMap.of(), null, true) assertThat(smm.getViewExists(42)).isTrue() } + + /** + * When a child is moved between parents (e.g. because an intermediate view is flattened or + * unflattened) while it is still being animated out of its previous parent, [ViewGroup] removes + * it from its children but keeps its parent pointer until the transition ends. addViewAt must + * still be able to insert it into the new parent. + */ + @Test + fun addViewAt_reparentsViewThatIsTransitioningOutOfPreviousParent() { + val smm = startSurface() + smm.createView("RCTView", 10, JavaOnlyMap.of(), null, null, true) + smm.createView("RCTView", 12, JavaOnlyMap.of(), null, null, true) + smm.createView("RCTView", 42, JavaOnlyMap.of(), null, null, true) + smm.addViewAt(surfaceId, 10, 0) + smm.addViewAt(surfaceId, 12, 1) + smm.addViewAt(10, 42, 0) + + val previousParent = smm.getView(10) as ViewGroup + val view = smm.getView(42) + previousParent.startViewTransition(view) + smm.removeViewAt(42, 10, 0) + assertThat(previousParent.indexOfChild(view)).isEqualTo(-1) + assertThat(view.parent).isSameAs(previousParent) + + smm.addViewAt(12, 42, 0) + + assertThat(view.parent).isSameAs(smm.getView(12)) + } }