Skip to content

Commit 0ba252b

Browse files
rozelefacebook-github-bot
authored andcommitted
Keep subview-clipping bookkeeping in sync when a tracked child is reparented or recycled (#57371)
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 0ba252b

9 files changed

Lines changed: 198 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: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import androidx.core.view.isNotEmpty
3333
import com.facebook.common.logging.FLog
3434
import com.facebook.react.R
3535
import com.facebook.react.common.ReactConstants
36+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
3637
import com.facebook.react.common.build.ReactBuildConfig
3738
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
3839
import com.facebook.react.uimanager.BackgroundStyleApplicator
@@ -61,6 +62,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA
6162
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END
6263
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START
6364
import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView
65+
import com.facebook.react.views.view.ClippingAwareViewRemover
6466
import com.facebook.systrace.Systrace
6567
import kotlin.math.abs
6668
import kotlin.math.ceil
@@ -287,11 +289,12 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
287289
scrollsChildToFocus = true
288290
}
289291

292+
@OptIn(UnstableReactNativeAPI::class)
290293
internal fun recycleView() {
291294
initView()
292-
if (parent != null) {
293-
(parent as ViewGroup).removeView(this)
294-
}
295+
// Route through the clipping-aware helper so a clipping parent does not retain a stale
296+
// `allChildren` reference to this recycled view.
297+
ClippingAwareViewRemover.removeFromParent(this)
295298
updateView()
296299
}
297300

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

Lines changed: 7 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<<83e5136ed4b37922bd298d3036fae133>>
88
*/
99

1010
/**
@@ -36,6 +36,7 @@ import com.facebook.common.logging.FLog
3636
import com.facebook.react.R
3737
import com.facebook.react.bridge.ReadableMap
3838
import com.facebook.react.common.ReactConstants
39+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
3940
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
4041
import com.facebook.react.uimanager.BackgroundStyleApplicator
4142
import com.facebook.react.uimanager.LengthPercentage
@@ -63,6 +64,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA
6364
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END
6465
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START
6566
import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView
67+
import com.facebook.react.views.view.ClippingAwareViewRemover
6668
import com.facebook.systrace.Systrace
6769
import kotlin.math.abs
6870
import kotlin.math.ceil
@@ -263,11 +265,12 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
263265
scrollsChildToFocus = true
264266
}
265267

268+
@OptIn(UnstableReactNativeAPI::class)
266269
internal fun recycleView() {
267270
initView()
268-
if (parent != null) {
269-
(parent as ViewGroup).removeView(this)
270-
}
271+
// Route through the clipping-aware helper so a clipping parent does not retain a stale
272+
// `allChildren` reference to this recycled view.
273+
ClippingAwareViewRemover.removeFromParent(this)
271274
updateView()
272275
}
273276

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.facebook.common.logging.FLog
2828
import com.facebook.react.R
2929
import com.facebook.react.bridge.ReadableMap
3030
import com.facebook.react.common.ReactConstants
31+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
3132
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
3233
import com.facebook.react.uimanager.BackgroundStyleApplicator
3334
import com.facebook.react.uimanager.LengthPercentage
@@ -55,6 +56,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA
5556
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END
5657
import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START
5758
import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView
59+
import com.facebook.react.views.view.ClippingAwareViewRemover
5860
import com.facebook.systrace.Systrace
5961
import kotlin.math.abs
6062
import kotlin.math.ceil
@@ -255,11 +257,12 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
255257
scrollsChildToFocus = true
256258
}
257259

260+
@OptIn(UnstableReactNativeAPI::class)
258261
internal fun recycleView() {
259262
initView()
260-
if (parent != null) {
261-
(parent as ViewGroup).removeView(this)
262-
}
263+
// Route through the clipping-aware helper so a clipping parent does not retain a stale
264+
// `allChildren` reference to this recycled view.
265+
ClippingAwareViewRemover.removeFromParent(this)
263266
updateView()
264267
}
265268

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

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.facebook.react.bridge.ReadableMap
3333
import com.facebook.react.bridge.UiThreadUtil.assertOnUiThread
3434
import com.facebook.react.bridge.UiThreadUtil.runOnUiThread
3535
import com.facebook.react.common.ReactConstants.TAG
36+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
3637
import com.facebook.react.config.ReactFeatureFlags
3738
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
3839
import com.facebook.react.touch.OnInterceptTouchEventListener
@@ -188,6 +189,7 @@ public open class ReactViewGroup public constructor(context: Context?) :
188189
nativeForegroundMap = null
189190
}
190191

192+
@OptIn(UnstableReactNativeAPI::class)
191193
internal open fun recycleView() {
192194
recycleCount++
193195

@@ -208,10 +210,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
208210
removeAllViews()
209211

210212
// 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-
}
213+
// before we can recycle it. Route through the clipping-aware helper so a clipping parent
214+
// does not retain a stale `allChildren` reference to this recycled view.
215+
ClippingAwareViewRemover.removeFromParent(this)
215216

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

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.content.Context
1212
import android.view.View
1313
import android.view.ViewGroup
1414
import android.widget.FrameLayout
15+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
1516
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
1617
import org.assertj.core.api.Assertions.assertThat
1718
import org.junit.Before
@@ -20,6 +21,7 @@ import org.junit.runner.RunWith
2021
import org.robolectric.Robolectric
2122
import org.robolectric.RobolectricTestRunner
2223

24+
@OptIn(UnstableReactNativeAPI::class)
2325
@RunWith(RobolectricTestRunner::class)
2426
class ReactViewGroupTest {
2527

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

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

0 commit comments

Comments
 (0)