Skip to content

Commit d3f2974

Browse files
committed
fix(hit-testing): ignore views with non-invertible transforms (scale 0)
Views with `transform: [{scaleY: 0}]` or `{scaleX: 0}` were still receiving touches on both iOS and Android. The symptoms looked different: Android "inherited" a hit region from a sibling view; iOS collapsed the touch point onto the degenerate axis. Both reduce to the same root cause: when a transform can't be inverted, the platform APIs silently fall back to stale or degenerate data during hit testing. Android: `TouchTargetHelper.getChildPoint` called `Matrix.invert(inverseMatrix)` without checking its return value. `Matrix.invert` returns false for a non-invertible matrix and leaves its destination unchanged. Because `inverseMatrix` is a class-level field reused across every child-point conversion, a failure meant we applied the previous view's inverse to the current view's touch point, the exact "inherits from another view" symptom. It also explains why shrinking a view from `scaleY: 0.9` to `0.0` left a 90% hit region: the 0.9 frame had populated the cache. Made `getChildPoint` return Boolean and skip children whose matrix cannot be inverted. iOS: `CGAffineTransformInvert` returns the original matrix when it can't invert, so `-[UIView convertPoint:fromView:]` collapses the touch point onto the degenerate axis and `-pointInside:` still reports a hit. Added a small `RCTLayerTransformCollapsesAxis` helper that rejects the hit when the 2x2 projection of `self.layer.transform` has determinant ~ 0. Applied in both `RCTViewComponentView.mm` (new arch) and `RCTView.m` (legacy arch). Tests: * `TouchTargetHelperTest` covers initial scaleX/scaleY: 0, zero-scale parent, the "inherits from sibling" regression, the 0.9 to 0.0 transition, and the touch-path accumulator. * `RCTViewComponentViewTests` gets parallel iOS cases. * RNTester: added a "Zero-scale hit test" entry under Transforms so this stays visually verifiable. Fixes #50797
1 parent 5f69a91 commit d3f2974

6 files changed

Lines changed: 348 additions & 5 deletions

File tree

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,20 @@ - (void)setPropKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN:(NSSet<NSString *
718718
return _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN;
719719
}
720720

721+
// Rejects hit tests against views whose 2D transform collapses an axis (e.g. `scaleX: 0`,
722+
// `scaleY: 0`, or any other non-invertible affine). Such views are visually degenerate, and
723+
// UIKit's `-convertPoint:fromView:` falls back to the original matrix when
724+
// `CGAffineTransformInvert` can't invert — so without this check the degenerate transform is
725+
// applied to the touch point and the view can still register hits along the collapsed axis.
726+
static BOOL RCTLayerTransformCollapsesAxis(CALayer *layer)
727+
{
728+
CATransform3D t = layer.transform;
729+
// Determinant of the 2x2 projection onto the XY plane. Anything non-zero is invertible; we
730+
// treat values within float epsilon as zero to avoid numerical issues near machine precision.
731+
CGFloat det = t.m11 * t.m22 - t.m12 * t.m21;
732+
return fabs(det) < (CGFloat)1e-6;
733+
}
734+
721735
- (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
722736
{
723737
// This is a classic textbook implementation of `hitTest:` with a couple of improvements:
@@ -754,6 +768,9 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
754768

755769
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
756770
{
771+
if (RCTLayerTransformCollapsesAxis(self.layer)) {
772+
return nil;
773+
}
757774
switch (_props->pointerEvents) {
758775
case PointerEventsMode::Auto:
759776
return [self betterHitTest:point withEvent:event];

packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,45 @@ - (void)testUnmountAfterToggleOffCleansUpReactSubviews
142142
XCTAssertNil(child2.superview);
143143
}
144144

145+
#pragma mark - hitTest against non-invertible transforms (#50797)
146+
147+
- (void)testHitTestReturnsNilForZeroScaleYView
148+
{
149+
RCTViewComponentView *view = [RCTViewComponentView new];
150+
view.frame = CGRectMake(0, 0, 100, 100);
151+
view.layer.transform = CATransform3DMakeScale(1, 0, 1);
152+
153+
XCTAssertNil([view hitTest:CGPointMake(50, 50) withEvent:nil]);
154+
}
155+
156+
- (void)testHitTestReturnsNilForZeroScaleXView
157+
{
158+
RCTViewComponentView *view = [RCTViewComponentView new];
159+
view.frame = CGRectMake(0, 0, 100, 100);
160+
view.layer.transform = CATransform3DMakeScale(0, 1, 1);
161+
162+
XCTAssertNil([view hitTest:CGPointMake(50, 50) withEvent:nil]);
163+
}
164+
165+
- (void)testHitTestReturnsSelfForIdentityTransform
166+
{
167+
RCTViewComponentView *view = [RCTViewComponentView new];
168+
view.frame = CGRectMake(0, 0, 100, 100);
169+
170+
XCTAssertEqual([view hitTest:CGPointMake(50, 50) withEvent:nil], view);
171+
}
172+
173+
- (void)testHitTestAfterScaleTransitionedToZeroReturnsNil
174+
{
175+
// #50797 variant: a view scaled to 0.9 first and then to 0.0 should stop receiving hits.
176+
RCTViewComponentView *view = [RCTViewComponentView new];
177+
view.frame = CGRectMake(0, 0, 100, 100);
178+
179+
view.layer.transform = CATransform3DMakeScale(1, 0.9, 1);
180+
XCTAssertEqual([view hitTest:CGPointMake(50, 50) withEvent:nil], view);
181+
182+
view.layer.transform = CATransform3DMakeScale(1, 0, 1);
183+
XCTAssertNil([view hitTest:CGPointMake(50, 50) withEvent:nil]);
184+
}
185+
145186
@end

packages/react-native/React/Views/RCTView.m

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,31 @@ - (void)setPointerEvents:(RCTPointerEvents)pointerEvents
179179
}
180180
}
181181

182+
// Rejects hit tests against views whose 2D transform collapses an axis (e.g. `scaleX: 0`,
183+
// `scaleY: 0`, or any other non-invertible affine). Such views are visually degenerate, and
184+
// UIKit's `-convertPoint:fromView:` falls back to the original matrix when
185+
// `CGAffineTransformInvert` can't invert — so without this check the degenerate transform is
186+
// applied to the touch point and the view can still register hits along the collapsed axis.
187+
static BOOL RCTLayerTransformCollapsesAxis(CALayer *layer)
188+
{
189+
CATransform3D t = layer.transform;
190+
// Determinant of the 2x2 projection onto the XY plane. Anything non-zero is invertible; we
191+
// treat values within float epsilon as zero to avoid numerical issues near machine precision.
192+
CGFloat det = t.m11 * t.m22 - t.m12 * t.m21;
193+
return fabs(det) < (CGFloat)1e-6;
194+
}
195+
182196
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
183197
{
184198
BOOL canReceiveTouchEvents = ([self isUserInteractionEnabled] && ![self isHidden]);
185199
if (!canReceiveTouchEvents) {
186200
return nil;
187201
}
188202

203+
if (RCTLayerTransformCollapsesAxis(self.layer)) {
204+
return nil;
205+
}
206+
189207
// `hitSubview` is the topmost subview which was hit. The hit point can
190208
// be outside the bounds of `view` (e.g., if -clipsToBounds is NO).
191209
UIView *hitSubview = nil;

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,11 @@ public object TouchTargetHelper {
212212
for (i in childrenCount - 1 downTo 0) {
213213
val child = viewGroup.getChildAt(i)
214214
val childPoint = tempPoint
215-
getChildPoint(eventCoords[0], eventCoords[1], viewGroup, child, childPoint)
215+
if (!getChildPoint(eventCoords[0], eventCoords[1], viewGroup, child, childPoint)) {
216+
// Child's transform is non-invertible (e.g. scaleX: 0 or scaleY: 0): the child is
217+
// visually degenerate, so it must not receive touches.
218+
continue
219+
}
216220
// The childPoint value will contain the view coordinates relative to the child.
217221
// We need to store the existing X,Y for the viewGroup away as it is possible this child
218222
// will not actually be the target and so we restore them if not
@@ -277,29 +281,38 @@ public object TouchTargetHelper {
277281
/**
278282
* Returns the coordinates of a touch in the child View. It is transform-aware and will invert the
279283
* transform Matrix to find the true local points. This code is taken from {@link
280-
* ViewGroup#isTransformedTouchPointInView()}
284+
* ViewGroup#isTransformedTouchPointInView()}.
285+
*
286+
* Returns `true` when [outLocalPoint] was populated with coordinates in the child's coordinate
287+
* space. Returns `false` when the child's transform matrix is not invertible (for example
288+
* `scaleX: 0` or `scaleY: 0`). On `false`, [outLocalPoint] is left in an indeterminate state and
289+
* callers must skip the child — the shared [inverseMatrix] field is retained from the previous
290+
* successful `invert`, so using it here would leak coordinates from another view.
281291
*/
282292
private fun getChildPoint(
283293
x: Float,
284294
y: Float,
285295
parent: ViewGroup,
286296
child: View,
287297
outLocalPoint: PointF,
288-
) {
298+
): Boolean {
289299
var localX = x + parent.scrollX - child.left
290300
var localY = y + parent.scrollY - child.top
291301
val matrix = child.matrix
292302
if (!matrix.isIdentity) {
303+
val inverseMatrix = inverseMatrix
304+
if (!matrix.invert(inverseMatrix)) {
305+
return false
306+
}
293307
val localXY = matrixTransformCoords
294308
localXY[0] = localX
295309
localXY[1] = localY
296-
val inverseMatrix = inverseMatrix
297-
matrix.invert(inverseMatrix)
298310
inverseMatrix.mapPoints(localXY)
299311
localX = localXY[0]
300312
localY = localXY[1]
301313
}
302314
outLocalPoint.set(localX, localY)
315+
return true
303316
}
304317

305318
/**
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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.uimanager
9+
10+
import android.content.Context
11+
import android.view.ViewGroup
12+
import android.widget.FrameLayout
13+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
14+
import com.facebook.react.views.view.ReactViewGroup
15+
import org.assertj.core.api.Assertions.assertThat
16+
import org.junit.Before
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import org.robolectric.RobolectricTestRunner
20+
import org.robolectric.RuntimeEnvironment
21+
22+
/**
23+
* Regression tests for facebook/react-native#50797: a view with a non-invertible transform (e.g.
24+
* `scaleX: 0` or `scaleY: 0`) must not receive touches, and must not silently reuse the cached
25+
* inverse matrix of a previously-processed view.
26+
*/
27+
@RunWith(RobolectricTestRunner::class)
28+
class TouchTargetHelperTest {
29+
30+
private lateinit var context: Context
31+
private lateinit var root: ViewGroup
32+
33+
@Before
34+
fun setUp() {
35+
ReactNativeFeatureFlagsForTests.setUp()
36+
context = RuntimeEnvironment.getApplication()
37+
root = FrameLayout(context)
38+
root.id = ROOT_TAG
39+
root.measure(500, 500)
40+
root.layout(0, 0, 500, 500)
41+
}
42+
43+
@Test
44+
fun normalChild_receivesTouchInsideItsBounds() {
45+
val child = ReactViewGroup(context)
46+
child.id = CHILD_TAG
47+
root.addView(child)
48+
child.layout(0, 0, 200, 200)
49+
50+
val tag = TouchTargetHelper.findTargetTagForTouch(100f, 100f, root)
51+
52+
assertThat(tag).isEqualTo(CHILD_TAG)
53+
}
54+
55+
@Test
56+
fun zeroScaleY_childDoesNotReceiveTouch() {
57+
val child = ReactViewGroup(context)
58+
child.id = CHILD_TAG
59+
root.addView(child)
60+
child.layout(0, 0, 200, 200)
61+
child.scaleY = 0f
62+
63+
val tag = TouchTargetHelper.findTargetTagForTouch(100f, 100f, root)
64+
65+
// Touch falls through to the root because the child is visually degenerate.
66+
assertThat(tag).isEqualTo(ROOT_TAG)
67+
}
68+
69+
@Test
70+
fun zeroScaleX_childDoesNotReceiveTouch() {
71+
val child = ReactViewGroup(context)
72+
child.id = CHILD_TAG
73+
root.addView(child)
74+
child.layout(0, 0, 200, 200)
75+
child.scaleX = 0f
76+
77+
val tag = TouchTargetHelper.findTargetTagForTouch(100f, 100f, root)
78+
79+
assertThat(tag).isEqualTo(ROOT_TAG)
80+
}
81+
82+
@Test
83+
fun zeroScaleYParent_childInsideIsNotTouchable() {
84+
val parent = ReactViewGroup(context)
85+
parent.id = PARENT_TAG
86+
val child = ReactViewGroup(context)
87+
child.id = CHILD_TAG
88+
root.addView(parent)
89+
parent.addView(child)
90+
parent.layout(0, 0, 200, 200)
91+
child.layout(0, 0, 200, 200)
92+
parent.scaleY = 0f
93+
94+
val tag = TouchTargetHelper.findTargetTagForTouch(100f, 100f, root)
95+
96+
// Neither the zero-scaled parent nor its child should receive the touch.
97+
assertThat(tag).isEqualTo(ROOT_TAG)
98+
}
99+
100+
@Test
101+
fun zeroScaleY_doesNotInheritHitRegionFromSibling() {
102+
// This is the exact #50797 symptom: a zero-scaled subtree "inherits" the hit region of
103+
// another view because `Matrix.invert` fails silently and the cached inverse from the
104+
// previous successful invert is reused.
105+
val scaledSibling = ReactViewGroup(context)
106+
scaledSibling.id = SIBLING_TAG
107+
val zeroScaled = ReactViewGroup(context)
108+
zeroScaled.id = CHILD_TAG
109+
root.addView(scaledSibling)
110+
root.addView(zeroScaled)
111+
// Sibling: a normally-hit-testable view with a non-identity (invertible) transform, so that
112+
// `inverseMatrix` gets populated during traversal.
113+
scaledSibling.layout(0, 0, 200, 200)
114+
scaledSibling.scaleY = 0.5f
115+
// Zero-scaled view is placed somewhere the touch would only land on if the sibling's
116+
// inverse matrix were (wrongly) applied to it.
117+
zeroScaled.layout(300, 300, 500, 500)
118+
zeroScaled.scaleY = 0f
119+
120+
val tag = TouchTargetHelper.findTargetTagForTouch(100f, 100f, root)
121+
122+
// Must hit the sibling (visible, scale 0.5) — never the zero-scaled view.
123+
assertThat(tag).isEqualTo(SIBLING_TAG)
124+
}
125+
126+
@Test
127+
fun scaleTransitionedToZero_stopsReceivingTouches() {
128+
// Second variant of #50797: a view whose scale shrinks from 0.9 to 0 keeps responding to
129+
// touches over its previous hit region because the cached inverse from the 0.9 frame is
130+
// reused after `invert` fails.
131+
val child = ReactViewGroup(context)
132+
child.id = CHILD_TAG
133+
root.addView(child)
134+
child.layout(0, 0, 200, 200)
135+
136+
// Warm the cache with an invertible transform.
137+
child.scaleY = 0.9f
138+
val warmTag = TouchTargetHelper.findTargetTagForTouch(100f, 100f, root)
139+
assertThat(warmTag).isEqualTo(CHILD_TAG)
140+
141+
// Now collapse the view.
142+
child.scaleY = 0f
143+
val coldTag = TouchTargetHelper.findTargetTagForTouch(100f, 100f, root)
144+
145+
assertThat(coldTag).isEqualTo(ROOT_TAG)
146+
}
147+
148+
@Test
149+
fun zeroScaleChild_doesNotAppearInTouchPath() {
150+
val child = ReactViewGroup(context)
151+
child.id = CHILD_TAG
152+
root.addView(child)
153+
child.layout(0, 0, 200, 200)
154+
child.scaleY = 0f
155+
156+
val eventCoords = FloatArray(2)
157+
val path = TouchTargetHelper.findTargetPathAndCoordinatesForTouch(100f, 100f, root, eventCoords)
158+
159+
val ids: List<Int> = path.map(TouchTargetHelper.ViewTarget::getViewId)
160+
assertThat(ids).doesNotContain(CHILD_TAG)
161+
}
162+
163+
private companion object {
164+
const val ROOT_TAG = 1
165+
const val PARENT_TAG = 2
166+
const val CHILD_TAG = 3
167+
const val SIBLING_TAG = 4
168+
}
169+
}

0 commit comments

Comments
 (0)