Skip to content

Commit 1e8e182

Browse files
qflenmeta-codesync[bot]
authored andcommitted
fix(hit-testing): ignore views with non-invertible transforms (scale 0) (#56586)
Summary: Fixes #50797. A view with a non-invertible transform (e.g. `transform: [{scaleY: 0}]` or `{scaleX: 0}`) still received touches on both Android and iOS. On Android the view appeared to "inherit" a hit region from another view in the hierarchy; on iOS the view registered taps along the collapsed axis. Both symptoms collapse to the same native behaviour: when a transform can't be inverted, the platform APIs silently fall back to stale or degenerate data during hit testing. ### Root cause **Android.** `TouchTargetHelper.getChildPoint` calls `Matrix.invert(inverseMatrix)` without checking its return value. `Matrix.invert` returns `false` for a non-invertible matrix and **leaves its destination unchanged**. `inverseMatrix` is a class-level field reused across every child-point conversion, so on failure we apply *the previous view's* inverse to the current view's touch point: the exact "inherits from another view" behavior. It is also why shrinking a view from `scaleY: 0.9` to `0.0` leaves a 90 % hit region: the 0.9 frame populated the cache. **iOS.** `CGAffineTransformInvert` is documented to return the original matrix when it can't invert. `-[UIView convertPoint:fromView:]` uses that "inverse" during hit testing, so the touch point gets collapsed onto the degenerate axis (e.g. y = 0 for `scaleY: 0`) and the inherited `pointInside:` still reports a hit against a visually invisible view. ### Fix - **Android** (`TouchTargetHelper.kt`): `getChildPoint` now returns `Boolean`. It returns `false` when `Matrix.invert` fails; the child iteration loop skips such children. One behaviour change, no epsilon tuning, no shared state leaks. Resolves both symptom variants via the same code path. - **iOS** (`RCTViewComponentView.mm`, Fabric): add a small `RCTLayerTransformCollapsesAxis` helper that tests the determinant of the 2 × 2 XY projection of `layer.transform` and return `NO` from the existing `pointInside:` override when it collapses. - **Tests.** `TouchTargetHelperTest` (new, Robolectric): 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 four parallel iOS cases. All pass locally. - **RNTester.** New Transforms entry "Zero-scale hit test (regression for #50797)". Reproduces both variants and exposes a scale input so you can step through the transition interactively. ### Why not #53769? PR #53769 solved the Android side with a `hasZeroScale(view)` early-return plus an epsilon comparison on individual matrix entries, and handled the `invert` failure in `getChildPoint` by *falling back to untransformed coordinates*. That still lets the degenerate view be hit with its pre-transform bounds; the early-return then has to catch the miss. This branch fixes the root cause once: if the matrix can't be inverted we don't descend into the child. The iOS fix is the matching change on the Fabric side. ## Changelog: [GENERAL] [FIXED] - Views with a non-invertible transform (e.g. `scaleX: 0` or `scaleY: 0`) no longer receive touches on Android or iOS. Pull Request resolved: #56586 Test Plan: ### Unit tests - `./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.uimanager.TouchTargetHelperTest*'`: 7/7 pass. - iOS build-for-testing of `RNTesterUnitTests` scheme succeeds; the four new `RCTViewComponentViewTests` cases live next to the existing cases in `React/Tests/Mounting/`. - `./gradlew ktfmtCheck`, `clang-format --dry-run -Werror`, `prettier --check`, `flow`, and `eslint` all pass. ### Manual verification (Android) RNTester → Components → Transforms → "Zero-scale hit test (regression for #50797)". **Variant 1: tap the `scaleY: 0` row directly.** "Last tapped:" must stay `(none)`. | Before | After | | --- | --- | | <img src="https://raw.githubusercontent.com/qflen/react-native/6f3799dbb597630a91f43e460437d636796dbbe7/assets/50797/before-tap-zero-scale.png" width="260"> | <img src="https://raw.githubusercontent.com/qflen/react-native/6f3799dbb597630a91f43e460437d636796dbbe7/assets/50797/after-tap-zero-scale.png" width="260"> | | `Last tapped: zero-scale` (bug) | `Last tapped: (none)` | **Variant 2: tap the `scaleY: 0.5` row (①), then tap the `scaleY: 0` row (②).** Without the fix, the shared `inverseMatrix` cache populated by tap ① leaks into the zero-scale hit test on tap ②. After the fix, tap ② is correctly ignored and "Last tapped" still reflects tap ①. | Before | After | | --- | --- | | <img src="https://raw.githubusercontent.com/qflen/react-native/6f3799dbb597630a91f43e460437d636796dbbe7/assets/50797/before-tap-zero-after-05.png" width="260"> | <img src="https://raw.githubusercontent.com/qflen/react-native/6f3799dbb597630a91f43e460437d636796dbbe7/assets/50797/after-tap-zero-after-05.png" width="260"> | | `Last tapped: zero-scale` (stale cache) | `Last tapped: variable (scaleY=0.5)` | iOS behaves similarly. I tried the example on iPhone 17 Pro (Simulator, iOS 26.1) and the zero-scale pressable is correctly untouchable; driving synthetic taps on the simulator wasn't scripted here, so the iOS signal is the four XCTest cases plus manual verification. Reviewed By: fabriziocucci Differential Revision: D102590386 Pulled By: javache fbshipit-source-id: 863a653f17db72ff85135fd8eb344ccd1528eda7
1 parent 0c153e2 commit 1e8e182

5 files changed

Lines changed: 330 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
@@ -97,8 +97,25 @@ - (void)setContentView:(UIView *)contentView
9797
}
9898
}
9999

100+
// Rejects hits against views whose 2D transform collapses an axis (e.g. `scaleX: 0`,
101+
// `scaleY: 0`, or any other non-invertible affine). Such views are visually degenerate, and
102+
// UIKit's `-convertPoint:fromView:` falls back to the original matrix when
103+
// `CGAffineTransformInvert` can't invert, so without this check the degenerate transform is
104+
// applied to the touch point and the view can still register hits along the collapsed axis.
105+
static BOOL RCTLayerTransformCollapsesAxis(CALayer *layer)
106+
{
107+
CATransform3D t = layer.transform;
108+
// Determinant of the 2x2 projection onto the XY plane. Anything non-zero is invertible; we
109+
// treat values within float epsilon as zero to avoid numerical issues near machine precision.
110+
CGFloat det = t.m11 * t.m22 - t.m12 * t.m21;
111+
return fabs(det) < (CGFloat)1e-6;
112+
}
113+
100114
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
101115
{
116+
if (RCTLayerTransformCollapsesAxis(self.layer)) {
117+
return NO;
118+
}
102119
if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero)) {
103120
return [super pointInside:point withEvent:event];
104121
}

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/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)