Skip to content

Fix addViewAt crash for views held by startViewTransition on Android - #59776

Open
pawicao wants to merge 3 commits into
react:mainfrom
pawicao:@pawicao/android-addviewat-end-view-transition
Open

pawicao wants to merge 3 commits into
react:mainfrom
pawicao:@pawicao/android-addviewat-end-view-transition

Conversation

@pawicao

@pawicao pawicao commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Summary:

On Android, SurfaceMountingManager.addViewAt fails when it inserts a view that a parent still holds with ViewGroup.startViewTransition.

A held child can leave its old parent's child list while child.parent still points to that old parent:

oldParent.indexOfChild(view) == -1
view.parent == oldParent

The existing recovery in addViewAt calls removeView on the old parent. That does nothing for a view that is no longer in the child list, so addView throws addViewAt: failed to insert view ... into parent. The recovery also adds the tag to erroneouslyReaddedReactTags, so a later Remove of that tag returns early and the view is never removed.

React Native Screens holds the content of a removed screen this way during its exit animation (Screen.startRemovalTransition, NativeProxy.notifyScreenRemoved).
Reanimated shared element transitions will soon move the source view out of such a screen into the surface root (react-native-reanimated#11054), and the app crashes when going back.
Analysis: comment on #11054.
Reanimated is getting a workaround that releases the holds before the mount batch (react-native-reanimated#11101), but addViewAt is the better place for the fix.

The fix

Before the existing recovery, if the old parent no longer lists the view, call endViewTransition on it, then read view.parent again. If the parent is cleared, the view is inserted normally and the tag is not marked as re-added. If the parent is still set, the existing recovery and its diagnostics run as before.

  • Holds of removed views that are not inserted again stay in place, so normal exit animations do not change.
  • A view held more than once, for example by nested screens, is released by one call, because endViewTransition clears the parent when it removes the view from the disappearing children.
  • Nested carriers each go through the same check on their own insert.
  • ReactViewGroup.endViewTransition also clears its own childrenRemovedWhileTransitioning entry, so RN's clipping state stays in sync.

Changelog:

[ANDROID] [FIXED] - Fix addViewAt crash when a view that a parent still holds with startViewTransition is inserted into another parent

Test Plan:

New Robolectric tests in SurfaceMountingManagerViewTransitionTest, with the real SurfaceMountingManager and real Android ViewGroups:

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest \
  --tests 'com.facebook.react.fabric.SurfaceMountingManagerViewTransitionTest'
Test Without fix With fix
held child moves to root, then final removal fails: addViewAt: failed to insert view [12] into parent [1] at index 1 passes
nested held children (each parent holds twice, carrier in carrier) move, then final removal fails (same error) passes
held child moves with the root attached to a window fails (same error) passes
held child is put back into its old parent fails: ... into parent [10] at index 0 passes
control: held exit with no reinsert keeps its hold passes passes
control: unheld move passes passes
control: existing recovery for a child that another parent still lists passes passes

Other checks:

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.fabric.*' -PenableWarningsAsErrors=true  # 89 tests, 0 failures
./gradlew :packages:react-native:ReactAndroid:lintDebug -PenableWarningsAsErrors=true  # no issues in changed files
yarn format-check-kotlin  # pass

Device test: Reanimated apps/fabric-example (branch of #11054, without #11101), Android emulator, Debug, --active-arch-only, React Native built from source. The example app does not build against main (it needs RN 0.86 to 0.88), so this used v0.88.0 with only this SurfaceMountingManager.kt change applied.

  • Without the fix: Shared Element Transitions → Fade Content → Sun, wait for the push to end, tap the content to go back. The app crashes with addViewAt: failed to insert view [666] into parent [1] at index 3. The specified child already has a parent.
  • With the fix: the same path (3 times), the hardware Back button (4 times), a quick pop during the push, nested shared tags, and nested stack navigation. No crash, no addViewAt or "already has a parent" lines in logcat, and no shared view is left on the root after the pop.

Recordings

Before:

before-fade-content-tap-back.mp4

After:

after-fade-content-tap-back.mp4

A parent that holds a child with `ViewGroup.startViewTransition` keeps
`child.parent` after the child leaves its child list. When Fabric then
inserts that view into another parent, `addViewAt` sees a stale parent.
Its recovery calls `removeView`, which does nothing for a view that is
no longer listed, so `addView` throws "failed to insert view". The
recovery also marks the tag as re-added, so a later Remove of that tag
is skipped.

React Native Screens holds the content of a removed screen this way.
Reanimated shared element transitions move the source view out of such
a screen into the surface root, and crash on Android when going back.

Before the recovery, end the view transition when the old parent no
longer lists the view, then read the parent again. If it is cleared,
insert the view normally. If not, keep the existing recovery. Holds of
removed views that are not inserted again stay in place.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 3, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Nov 3, 2026
endViewTransitionIfRemovedFromParent(view)
val currParentView = view.parent
if (currParentView != null) {
val actualParentId = if (currParentView is ViewGroup) currParentView.id else View.NO_ID

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add this logic inline here, so we can avoid doing the same null / class checks?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! I did inline it in d7addde. As a result view.parent is now read once, and read again only after endViewTransition. Is this what you had in mind here? I could also potentially fold the two older is ViewGroup checks in the recovery block onto one as? ViewGroup value, so the parent's class is checked only once if you prefer

@meta-codesync

meta-codesync Bot commented Nov 3, 2026

Copy link
Copy Markdown

@javache has imported this pull request. If you are a Meta employee, you can view this in D129333127.

@mangeshraut712 mangeshraut712 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SurfaceMountingManager.addViewAt (SurfaceMountingManager.kt ~339–341), a held child is released with a single endViewTransition(view) when indexOfChild is -1.

ViewGroup.startViewTransition can be nested: each call appends the view to mTransitioningViews, and endViewTransition only removes one occurrence. The new nested test (addViewAt_movesNestedHeldChildren...) calls startViewTransition twice per parent via repeat(2) before removeViewAt, then relies on this one endViewTransition during addViewAt.

On a real ViewGroup that still leaves a hold, view.parent can stay non-null after the move/remove path and the “final removal” assertions can fail outside Robolectric. Loop endViewTransition while indexOfChild(view) == -1 && parent === currParentView (or while the view remains in the transitioning set), matching the number of outstanding starts.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants