Skip to content

Commit ec7dd13

Browse files
rozelefacebook-github-bot
authored andcommitted
Keep subview-clipping bookkeeping in sync when a tracked child is reparented or recycled
Summary: When a view tracked by a clipping-enabled parent (`removeClippedSubviews`) is detached from that parent with a raw `ViewGroup.removeView`, the parent's internal `allChildren` bookkeeping is not updated — that array is only kept in sync through the parent's clipping-aware removal path. The parent is then left holding a stale reference to a view that has been reparented elsewhere, and a later clipping pass asserts the child is still parented to it and throws an `IllegalStateException`, crashing the app. This change introduces a small shared helper, `ClippingAwareViewRemover.removeFromParent(view)`, which removes a view from its current parent through the clipping-aware path when that parent is a clipping-enabled `ReactViewGroup`, and otherwise falls back to a plain `removeView`. All recycle/reparent sites that previously did a raw self-detach now route through it: - `ReactViewGroup.recycleView` - `ReactScrollView.recycleView` / `ReactHorizontalScrollView.recycleView` / `ReactNestedScrollView.recycleView` (the nested variant is generated from `ReactScrollView` via `generate-nested-scroll-view.js`; the generated file is regenerated here) - `ReactTextView.recycleView` Also fixes the Fabric mounting layer's reparent recovery in `SurfaceMountingManager.addViewAt` (the "View already has a parent" evasive path) to remove the view via the parent's `ViewManager` rather than a raw `removeView`, and hardens `ReactViewGroup.removeViewWithSubviewClippingEnabled` to no-op safely when the view is not tracked in `allChildren` (it previously indexed `allChildren[-1]`). Behavior is unchanged for non-clipping parents, which still use the plain `removeView`. Changelog: [Android][Fixed] - Fix rare crash in views using `removeClippedSubviews` when a tracked child is reparented or recycled Differential Revision: D110059699
1 parent e442c49 commit ec7dd13

9 files changed

Lines changed: 186 additions & 19 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactSoftExceptionLogger.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import com.facebook.react.bridge.ReactSoftExceptionLogger.Categories.CLIPPING_PR
1414
import com.facebook.react.bridge.ReactSoftExceptionLogger.Categories.RVG_ADD_CHILDREN_FOR_ACCESSIBILITY
1515
import com.facebook.react.bridge.ReactSoftExceptionLogger.Categories.RVG_IS_VIEW_CLIPPED
1616
import com.facebook.react.bridge.ReactSoftExceptionLogger.Categories.RVG_ON_VIEW_REMOVED
17+
import com.facebook.react.bridge.ReactSoftExceptionLogger.Categories.RVG_REMOVE_VIEW_NOT_IN_ALL_CHILDREN
1718
import com.facebook.react.bridge.ReactSoftExceptionLogger.Categories.SOFT_ASSERTIONS
1819
import com.facebook.react.bridge.ReactSoftExceptionLogger.Categories.SURFACE_MOUNTING_MANAGER_MISSING_VIEWSTATE
1920
import java.util.concurrent.CopyOnWriteArrayList
@@ -25,6 +26,7 @@ internal object ReactSoftExceptionLogger {
2526
RVG_ADD_CHILDREN_FOR_ACCESSIBILITY,
2627
RVG_IS_VIEW_CLIPPED,
2728
RVG_ON_VIEW_REMOVED,
29+
RVG_REMOVE_VIEW_NOT_IN_ALL_CHILDREN,
2830
CLIPPING_PROHIBITED_VIEW,
2931
SOFT_ASSERTIONS,
3032
SURFACE_MOUNTING_MANAGER_MISSING_VIEWSTATE,
@@ -37,6 +39,8 @@ internal object ReactSoftExceptionLogger {
3739
"ReactViewGroup.addChildrenForAccessibility"
3840
const val RVG_IS_VIEW_CLIPPED: String = "ReactViewGroup.isViewClipped"
3941
const val RVG_ON_VIEW_REMOVED: String = "ReactViewGroup.onViewRemoved"
42+
const val RVG_REMOVE_VIEW_NOT_IN_ALL_CHILDREN: String =
43+
"ReactViewGroup.removeViewWithSubviewClippingEnabled:NotInAllChildren"
4044
const val CLIPPING_PROHIBITED_VIEW: String = "ReactClippingProhibitedView"
4145
const val SOFT_ASSERTIONS: String = "SoftAssertions"
4246
const val SURFACE_MOUNTING_MANAGER_MISSING_VIEWSTATE: String =

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ internal constructor(
360360
// thus prevent the RemoveDeleteTree worker from deleting this
361361
// View in the future.
362362
if (currParentView is ViewGroup) {
363-
currParentView.removeView(view)
363+
removeViewFromCurrentParent(currParentView, view)
364364
}
365365
erroneouslyReaddedReactTags.add(tag)
366366
}
@@ -395,6 +395,33 @@ internal constructor(
395395
}
396396
}
397397

398+
/**
399+
* Removes [view] from [currentParent] as part of [addViewAt]'s evasive recovery, keeping any
400+
* subview-clipping bookkeeping in sync.
401+
*
402+
* A raw [ViewGroup.removeView] only detaches the view from the Android hierarchy. When
403+
* [currentParent] manages subview clipping (`removeClippedSubviews`), it also keeps an internal
404+
* `allChildren` array that is only updated through its [ViewManager]'s removal path; a raw
405+
* removal leaves a stale entry there pointing at a view that has just been reparented elsewhere,
406+
* and a later clipping pass then trips the parent's `parent === this` invariant and crashes.
407+
* Routing the removal through the parent's [ViewManager] (when its [ViewState] is known) reuses
408+
* the clipping-aware removal and keeps the bookkeeping consistent; otherwise we fall back to the
409+
* raw removal.
410+
*/
411+
private fun removeViewFromCurrentParent(currentParent: ViewGroup, view: View) {
412+
val currentParentState = getNullableViewState(currentParent.id)
413+
if (currentParentState?.viewManager is IViewGroupManager<*>) {
414+
val manager = getViewGroupManager(currentParentState)
415+
for (i in 0 until manager.getChildCount(currentParent)) {
416+
if (manager.getChildAt(currentParent, i) === view) {
417+
manager.removeViewAt(currentParent, i)
418+
return
419+
}
420+
}
421+
}
422+
currentParent.removeView(view)
423+
}
424+
398425
@UiThread
399426
public fun removeViewAt(tag: Int, parentTag: Int, index: Int): Unit {
400427
UiThreadUtil.assertOnUiThread()

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA
6161
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END
6262
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START
6363
import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView
64+
import com.facebook.react.views.view.ClippingAwareViewRemover
6465
import com.facebook.systrace.Systrace
6566
import kotlin.math.abs
6667
import kotlin.math.ceil
@@ -289,9 +290,9 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
289290

290291
internal fun recycleView() {
291292
initView()
292-
if (parent != null) {
293-
(parent as ViewGroup).removeView(this)
294-
}
293+
// Route through the clipping-aware helper so a clipping parent does not retain a stale
294+
// `allChildren` reference to this recycled view.
295+
ClippingAwareViewRemover.removeFromParent(this)
295296
updateView()
296297
}
297298

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<2b0cbc5249ac34ae7f030a9c0fffd1f3>>
7+
* @generated SignedSource<<84f285077bc6fea8375aa8bc00e8d2b9>>
88
*/
99

1010
/**
@@ -63,6 +63,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA
6363
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END
6464
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START
6565
import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView
66+
import com.facebook.react.views.view.ClippingAwareViewRemover
6667
import com.facebook.systrace.Systrace
6768
import kotlin.math.abs
6869
import kotlin.math.ceil
@@ -265,9 +266,9 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
265266

266267
internal fun recycleView() {
267268
initView()
268-
if (parent != null) {
269-
(parent as ViewGroup).removeView(this)
270-
}
269+
// Route through the clipping-aware helper so a clipping parent does not retain a stale
270+
// `allChildren` reference to this recycled view.
271+
ClippingAwareViewRemover.removeFromParent(this)
271272
updateView()
272273
}
273274

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA
5555
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END
5656
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START
5757
import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView
58+
import com.facebook.react.views.view.ClippingAwareViewRemover
5859
import com.facebook.systrace.Systrace
5960
import kotlin.math.abs
6061
import kotlin.math.ceil
@@ -257,9 +258,9 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
257258

258259
internal fun recycleView() {
259260
initView()
260-
if (parent != null) {
261-
(parent as ViewGroup).removeView(this)
262-
}
261+
// Route through the clipping-aware helper so a clipping parent does not retain a stale
262+
// `allChildren` reference to this recycled view.
263+
ClippingAwareViewRemover.removeFromParent(this)
263264
updateView()
264265
}
265266

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import com.facebook.react.views.text.internal.span.CanvasEffectSpan;
5050
import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan;
5151
import com.facebook.react.views.text.internal.span.ReactTagSpan;
52+
import com.facebook.react.views.view.ClippingAwareViewRemover;
5253
import com.facebook.yoga.YogaMeasureMode;
5354

5455
@Nullsafe(Nullsafe.Mode.LOCAL)
@@ -104,10 +105,9 @@ private void initView() {
104105
initView();
105106

106107
// If the view is still attached to a parent, we need to remove it from the parent
107-
// before we can recycle it.
108-
if (getParent() != null) {
109-
((ViewGroup) getParent()).removeView(this);
110-
}
108+
// before we can recycle it. Route through the clipping-aware helper so a clipping parent
109+
// does not retain a stale `allChildren` reference to this recycled view.
110+
ClippingAwareViewRemover.removeFromParent(this);
111111

112112
BackgroundStyleApplicator.reset(this);
113113

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.view
9+
10+
import android.view.View
11+
import android.view.ViewGroup
12+
13+
/**
14+
* Helper for detaching a [View] from its current parent while keeping subview-clipping bookkeeping
15+
* consistent.
16+
*
17+
* When a parent [ReactViewGroup] has `removeClippedSubviews` enabled it maintains an internal
18+
* `allChildren` array that is only kept in sync through its clipping-aware removal path. A raw
19+
* [ViewGroup.removeView] detaches the child from the Android hierarchy but leaves a stale entry in
20+
* that array pointing at a view that may then be re-mounted under another parent, which later trips
21+
* the parent's `parent === this` clipping invariant and crashes. Recycling and reparenting paths
22+
* that detach a view from an arbitrary parent should route through here instead of calling
23+
* [ViewGroup.removeView] directly.
24+
*/
25+
public object ClippingAwareViewRemover {
26+
/**
27+
* Removes [view] from its current parent. If the parent is a clipping-enabled [ReactViewGroup],
28+
* the removal first goes through the clipping-aware path so `allChildren` stays in sync. Then, if
29+
* the view is still attached — a non-clipping parent, or a child not tracked in the clipping
30+
* bookkeeping — it is detached with a plain [ViewGroup.removeView]. No-op if [view] has no
31+
* [ViewGroup] parent.
32+
*/
33+
@JvmStatic
34+
public fun removeFromParent(view: View) {
35+
val parent = view.parent
36+
if (parent is ReactViewGroup && parent.removeClippedSubviews) {
37+
parent.removeViewWithSubviewClippingEnabled(view)
38+
}
39+
// `removeViewWithSubviewClippingEnabled` detaches tracked children itself; this handles the
40+
// non-clipping parent and the untracked-child cases (and is a no-op once already detached).
41+
(view.parent as? ViewGroup)?.removeView(view)
42+
}
43+
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
208208
removeAllViews()
209209

210210
// If the view is still attached to a parent, we need to remove it from the parent
211-
// before we can recycle it.
212-
if (parent != null) {
213-
(parent as ViewGroup).removeView(this)
214-
}
211+
// before we can recycle it. Route through the clipping-aware helper so a clipping parent
212+
// does not retain a stale `allChildren` reference to this recycled view.
213+
ClippingAwareViewRemover.removeFromParent(this)
215214

216215
// Reset background, borders
217216
updateBackgroundDrawable(null)
@@ -712,6 +711,20 @@ public open class ReactViewGroup public constructor(context: Context?) :
712711
val allChildren = checkNotNull(allChildren)
713712
view.removeOnLayoutChangeListener(childrenLayoutChangeListener)
714713
val index = indexOfChildInAllChildren(view)
714+
if (index < 0) {
715+
// The view is not tracked in allChildren (e.g. it was added before clipping was enabled or
716+
// never routed through the clipping-aware add). Return instead of indexing allChildren[-1];
717+
// there is nothing to remove from the clipping bookkeeping and detaching the view is the
718+
// caller's responsibility. Logged because we are unsure how often this happens in practice
719+
// and want to measure its frequency.
720+
logSoftException(
721+
ReactSoftExceptionLogger.Categories.RVG_REMOVE_VIEW_NOT_IN_ALL_CHILDREN,
722+
ReactNoCrashSoftException(
723+
"removeViewWithSubviewClippingEnabled: view not in allChildren. parentThis=${view.parent === this} allChildrenCount=$allChildrenCount recycleCount=$recycleCount"
724+
),
725+
)
726+
return
727+
}
715728
if (!isViewClipped(allChildren[index], index)) {
716729
var clippedSoFar = 0
717730
for (i in 0..<index) {

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,83 @@ class ReactViewGroupTest {
6363
rvg.updateClippingRect()
6464
assertThat(rvg.childCount).isEqualTo(20)
6565
}
66+
67+
@Test
68+
fun `ClippingAwareViewRemover - removes a tracked child from a clipping parent without leaving a stale entry`() {
69+
val rvg = ReactViewGroup(context)
70+
rvg.left = 0
71+
rvg.right = 100
72+
rvg.top = 0
73+
rvg.bottom = 100
74+
FrameLayout(context).addView(rvg)
75+
rvg.removeClippedSubviews = true
76+
val child = TestView(context, 0)
77+
rvg.addViewWithSubviewClippingEnabled(child, 0)
78+
rvg.updateClippingRect()
79+
assertThat(rvg.allChildrenCount).isEqualTo(1)
80+
assertThat(child.parent).isSameAs(rvg)
81+
82+
ClippingAwareViewRemover.removeFromParent(child)
83+
84+
// The clipping bookkeeping stays in sync (no stale allChildren entry) ...
85+
assertThat(rvg.allChildrenCount).isEqualTo(0)
86+
assertThat(child.parent).isNull()
87+
// ... so a subsequent clipping pass does not trip the invalid-clipping-state invariant.
88+
rvg.updateClippingRect()
89+
}
90+
91+
@Test
92+
fun `ClippingAwareViewRemover - falls back to a plain removeView for a non-clipping parent`() {
93+
val parent = ReactViewGroup(context)
94+
val child = TestView(context, 0)
95+
parent.addView(child)
96+
assertThat(child.parent).isSameAs(parent)
97+
98+
ClippingAwareViewRemover.removeFromParent(child)
99+
100+
assertThat(child.parent).isNull()
101+
assertThat(parent.childCount).isEqualTo(0)
102+
}
103+
104+
@Test
105+
fun `removeViewWithSubviewClippingEnabled - is a no-op for a view not tracked in allChildren`() {
106+
val rvg = ReactViewGroup(context)
107+
rvg.left = 0
108+
rvg.right = 100
109+
rvg.top = 0
110+
rvg.bottom = 100
111+
FrameLayout(context).addView(rvg)
112+
rvg.removeClippedSubviews = true
113+
114+
// A view that was never added through the clipping-aware path is not in allChildren; removing
115+
// it must not index allChildren[-1] / throw (it logs a soft exception and returns instead).
116+
rvg.removeViewWithSubviewClippingEnabled(TestView(context, 0))
117+
118+
assertThat(rvg.allChildrenCount).isEqualTo(0)
119+
}
120+
121+
@Test
122+
fun `ClippingAwareViewRemover - detaches an attached child that is not tracked in allChildren`() {
123+
val rvg = ReactViewGroup(context)
124+
rvg.left = 0
125+
rvg.right = 100
126+
rvg.top = 0
127+
rvg.bottom = 100
128+
FrameLayout(context).addView(rvg)
129+
rvg.removeClippedSubviews = true
130+
// Add through the raw ViewGroup API so the child is a real child but is NOT tracked in
131+
// allChildren (the clipping-aware removal can't find it).
132+
val child = TestView(context, 0)
133+
rvg.addView(child)
134+
assertThat(child.parent).isSameAs(rvg)
135+
assertThat(rvg.allChildrenCount).isEqualTo(0)
136+
137+
ClippingAwareViewRemover.removeFromParent(child)
138+
139+
// The clipping-aware removal returns early (untracked); the helper's removeView fallback still
140+
// detaches the child.
141+
assertThat(child.parent).isNull()
142+
}
66143
}
67144

68145
class TestView(context: Context, yPos: Int) : View(context) {

0 commit comments

Comments
 (0)