fix(iOS): boxShadow + overflow hidden combination affecting pointer event box-none and scale transform. (#52413)

Summary:
This issue is only reproducible with combination of `overflow: hidden` and `boxShadow` i.e. when a [container view](https://github.com/facebook/react-native/blob/2ce7eab5f998788ffa8346b63cc4901d026456ba/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#L799) is added to the hierarchy.

### Case 1:

```jsx
<Pressable
  pointerEvents="box-none"
  style={{ width: 300, height: 300, boxShadow: "0 0 100px 0 rgba(0, 0, 0, 0.5)", overflow: "hidden" }}
  onPress={() => {
    alert("Tapped");
  }}
/>
```

Here the view has `box-none` but it will still trigger the press event because this [line](https://github.com/facebook/react-native/blob/2ce7eab5f998788ffa8346b63cc4901d026456ba/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#L658) returns the `_containerView` as `hitView` and this condition gets [fullfilled](https://github.com/facebook/react-native/blob/2ce7eab5f998788ffa8346b63cc4901d026456ba/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#L676) (since it is not self). To fix this issue we should iterate on `self.currentContainerView.subviews`.

### Case 2:

Transform scale clipping child view.

```jsx
<Pressable
  pointerEvents="box-none"
  style={{ width: 300, height: 300, overflow: "hidden",
    transform: [
      { scale: 0.5 },
    ],
    boxShadow: "0 0 100px 0 rgba(0, 0, 0, 0.5)",
  }}
  onPress={() => {
    alert("parent view");
  }}
>
  <Pressable
    style={{
      position: "absolute",
      bottom: 0,
      right: 0,
      backgroundColor: "blue",
      height: 50,
      width: 100,
    }}
    onPress={() => {
      alert("child view");
    }}
  >
    <Text>Press me</Text>
  </Pressable>
</Pressable>;
```

The above style hides the child view on iOS whereas it is visible on android.

| iOS | Android |
   |---|---|
   | <img src="https://github.com/user-attachments/assets/14713ec9-1ae7-4425-9348-946d507188e8" width="200px" /> | <img src="https://github.com/user-attachments/assets/0f6e2368-037d-4b03-8a85-8a08f1578ff6" width="200px" /> |

This happens because `containerView` uses `self.frame` property which gets mutated due to scale transform. So containerView becomes `actualSize * scale * scale` instead of `actualSize * scale` and ends up clipping child views because of overflow hidden. Please checkout the layout hierarchy in the below screenshot. To fix it we need to use `self.bounds` instead of `self.frame` when setting the frame for container.

<img src="https://github.com/user-attachments/assets/9ce41756-f41f-4782-bfc9-b0c281f314b7" width="300px" />

## Changelog:

[IOS] [FIXED] - boxShadow + overflow hidden combination interfering with pointerEvents and transform scale.

<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests

Pull Request resolved: https://github.com/facebook/react-native/pull/52413

Test Plan:
Test above example snippets and pointer events examples in RNTester.

cc - joevilches

Reviewed By: cortinico, jorge-cab

Differential Revision: D77872110

Pulled By: joevilches

fbshipit-source-id: e9693c79b47379531510607921ffaa6d6cc5c17b
This commit is contained in:
nishan (o^▽^o)
2025-09-18 14:06:00 -07:00
committed by Facebook GitHub Bot
parent 1c5e0ffd9f
commit c8e5f9766b
2 changed files with 43 additions and 4 deletions
@@ -650,7 +650,9 @@ const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f;
BOOL isPointInside = [self pointInside:point withEvent:event];
BOOL clipsToBounds = self.currentContainerView.clipsToBounds;
UIView *currentContainerView = self.currentContainerView;
BOOL clipsToBounds = currentContainerView.clipsToBounds;
clipsToBounds = clipsToBounds || _layoutMetrics.overflowInset == EdgeInsets{};
@@ -658,8 +660,8 @@ const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f;
return nil;
}
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event];
for (UIView *subview = nullptr in [currentContainerView.subviews reverseObjectEnumerator]) {
UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:currentContainerView] withEvent:event];
if (hitView) {
return hitView;
}
@@ -859,7 +861,7 @@ static RCTBorderStyle RCTBorderStyleFromOutlineStyle(OutlineStyle outlineStyle)
if (_useCustomContainerView) {
if (!_containerView) {
_containerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.frame.size.width, self.frame.size.height)];
_containerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height)];
for (UIView *subview = nullptr in effectiveContentView.subviews) {
[_containerView addSubview:subview];
}
@@ -568,6 +568,43 @@ const examples = [
return <PressableAriaLabel />;
},
},
{
title: 'Pressable with box-shadow',
description: ('Pressables with box-shadow': string),
render: function PressableWithBoxShadow(): React.Node {
const [parentColor, setParentColor] = useState('red');
const [childColor, setChildColor] = useState('blue');
return (
<Pressable
pointerEvents="box-none"
style={{
width: 300,
height: 300,
overflow: 'hidden',
transform: [{scale: 0.5}],
boxShadow: '0 0 100px 0 rgba(0, 0, 0, 0.5)',
backgroundColor: parentColor,
}}
onPress={() => {
setParentColor(parentColor === 'red' ? 'orange' : 'red');
}}>
<Pressable
style={{
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: childColor,
height: 50,
width: 100,
}}
onPress={() => {
setChildColor(childColor === 'blue' ? 'green' : 'blue');
}}
/>
</Pressable>
);
},
},
...PressableExampleFbInternal.examples,
];