Skip to content

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

Merged
meta-codesync[bot] merged 3 commits into
react:mainfrom
pawicao:@pawicao/android-addviewat-end-view-transition
Nov 4, 2026
Merged

meta-codesync[bot] merged 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.

@meta-codesync
meta-codesync Bot merged commit 45152f1 into react:main Nov 4, 2026
55 checks passed
@react-native-bot

Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @pawicao in 45152f1

When will my fix make it into a release? | How to file a pick request?

@react-native-bot react-native-bot added the Merged This PR has been merged. label Nov 4, 2026
@react-native-bot

Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @pawicao in 45152f1

When will my fix make it into a release? | How to file a pick request?

fabriziocucci pushed a commit that referenced this pull request Nov 8, 2026
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The three fields below are mandatory. -->

## Summary:

<!-- Explain the **motivation** for making this change. What existing problem does the pull request solve? -->

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:

```text
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`](https://github.com/software-mansion/react-native-screens/blob/4.27.0/android/src/main/java/com/swmansion/rnscreens/Screen.kt), [`NativeProxy.notifyScreenRemoved`](https://github.com/software-mansion/react-native-screens/blob/4.27.0/android/src/fabric/java/com/swmansion/rnscreens/NativeProxy.kt)).
Reanimated shared element transitions will soon move the source view out of such a screen into the surface root ([react-native-reanimated#11054](software-mansion/react-native-reanimated#11054)), and the app crashes when going back.
Analysis: [comment on #11054](software-mansion/react-native-reanimated#11054 (comment)).
Reanimated is getting a workaround that releases the holds before the mount batch ([react-native-reanimated#11101](software-mansion/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`](https://developer.android.com/reference/android/view/ViewGroup#endViewTransition(android.view.View)) 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:

<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

[ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests
-->

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

## Test Plan:

<!-- Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes the user interface. -->

New Robolectric tests in `SurfaceMountingManagerViewTransitionTest`, with the real `SurfaceMountingManager` and real Android `ViewGroup`s:

```sh
./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:

```sh
./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:**

https://github.com/user-attachments/assets/40cd095f-909e-47b4-b741-16cdc9751bba

**After:**

https://github.com/user-attachments/assets/db6d35b5-c9e7-40ec-8632-2b96f79188f6

Differential Revision: D129333127

Pull Request resolved: #59776

(cherry picked from commit 45152f1)
@react-native-bot

Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @pawicao in f1cf8a1

When will my fix make it into a release? | How to file a pick request?

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. Merged This PR has been merged. 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.

4 participants