Skip to content

Commit c8d66b4

Browse files
authored
[Web] Make getEffectiveBoundingRect recursive (#4303)
## Description Currently `getEffectiveBoundingRect` takes bounding box either from view, or children if element has `display: contents;`. However, in case of nested detectors this brakes, as can be seen when running the example code below. This PR makes `getEffectiveBoundingRect` recursive, so it will not stop on the first layer of children. ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import React, { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, useHoverGesture, useNativeGesture, } from 'react-native-gesture-handler'; // Web-only repro for the getEffectiveBoundingRect fix. // // The OUTER hover gesture has a `hitSlop`, which makes its handler run // checkHitSlop() → measureView() → getEffectiveBoundingRect() on the detector's // `display:contents` host. The INNER GestureDetector nests a SECOND // `display:contents` host between that host and the real box (the same shape as // a hover detector wrapping a Touchable). Before the fix, the outer host's only // child was itself `display:contents`, so its measured rect collapsed to 0×0 at // the origin and checkHitSlop rejected every real pointer — hover never fired. // After the fix, getEffectiveBoundingRect recurses through the inner // `display:contents` child to the real box, so hover activates normally. export default function EmptyExample() { const [hovered, setHovered] = useState(false); const hover = useHoverGesture({ manualActivation: true, disableReanimated: true, hitSlop: { top: 10, bottom: 10, left: 10, right: 10 }, onBegin: () => setHovered(true), onFinalize: () => setHovered(false), }); // Any inner gesture works — it exists only to nest a second detector host. const inner = useNativeGesture({}); return ( <View style={styles.container}> <GestureDetector gesture={hover}> <GestureDetector gesture={inner}> <View style={[styles.box, hovered && styles.boxHovered]}> <Text style={styles.label}> {hovered ? 'hovered ✅' : 'hover me (web)'} </Text> </View> </GestureDetector> </GestureDetector> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, box: { width: 220, height: 130, borderRadius: 16, backgroundColor: '#a78bfa', alignItems: 'center', justifyContent: 'center', }, boxHovered: { backgroundColor: '#34d399', }, label: { fontSize: 18, fontWeight: '700', color: '#1a1a2e' }, }); ``` </details>
1 parent 8bfaefa commit c8d66b4

2 files changed

Lines changed: 36 additions & 15 deletions

File tree

packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { SingleGestureName } from '../../v3/types';
66
import type IGestureHandler from '../handlers/IGestureHandler';
77
import type { SVGRef } from '../interfaces';
88
import {
9+
firstNonContentsView,
910
getEffectiveBoundingRect,
11+
hasDisplayContents,
1012
isPointerInBounds,
1113
isRNSVGElement,
1214
} from '../utils';
@@ -141,11 +143,9 @@ export class GestureHandlerWebDelegate
141143
throw new Error(tagMessage('Cannot convert coords on a null view'));
142144
}
143145

144-
const localView =
145-
this.gestureHandler.usesNativeOrVirtualDetector() &&
146-
this.view.style.display === 'contents'
147-
? (this.view.children[0] as HTMLElement)
148-
: this.view;
146+
const localView = this.gestureHandler.usesNativeOrVirtualDetector()
147+
? firstNonContentsView(this.view)
148+
: this.view;
149149

150150
const rect = getEffectiveBoundingRect(localView);
151151
const transform = getComputedStyle(localView).transform;
@@ -345,11 +345,7 @@ export class GestureHandlerWebDelegate
345345
): void {
346346
this.ensureView(this.view);
347347

348-
const hasDisplayContents =
349-
this.view.style.display === 'contents' ||
350-
getComputedStyle(this.view).display === 'contents';
351-
352-
if (hasDisplayContents) {
348+
if (hasDisplayContents(this.view)) {
353349
for (const child of Array.from(this.view.children)) {
354350
if (child instanceof HTMLElement) {
355351
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access

packages/react-native-gesture-handler/src/web/utils.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,52 @@ import type { StylusData } from '../handlers/gestureHandlerCommon';
22
import { PointerType } from '../PointerType';
33
import type { GestureHandlerRef, Point, SVGRef } from './interfaces';
44

5+
export function hasDisplayContents(view: HTMLElement): boolean {
6+
return (
7+
view.style.display === 'contents' ||
8+
getComputedStyle(view).display === 'contents'
9+
);
10+
}
11+
12+
export function firstNonContentsView(view: HTMLElement): HTMLElement {
13+
let current = view;
14+
15+
while (hasDisplayContents(current) && current.childElementCount > 0) {
16+
current = current.children[0] as HTMLElement;
17+
}
18+
19+
return current;
20+
}
21+
522
// For display: contents elements (like the gesture detector wrapper), getBoundingClientRect
6-
// returns all zeros since the element has no box. Compute the bounding box from children instead.
23+
// returns all zeros since the element has no box. Derive the bounds from the children instead
24+
// (recurse until we reach elements that actually have a box).
725
export function getEffectiveBoundingRect(view: HTMLElement): DOMRect {
8-
if (view.style.display === 'contents' && view.children.length > 0) {
26+
if (hasDisplayContents(view) && view.children.length > 0) {
927
let minX = Infinity;
1028
let minY = Infinity;
1129
let maxX = -Infinity;
1230
let maxY = -Infinity;
1331

1432
// eslint-disable-next-line @typescript-eslint/prefer-for-of
1533
for (let i = 0; i < view.children.length; i++) {
16-
const childRect = (
34+
const childRect = getEffectiveBoundingRect(
1735
view.children[i] as HTMLElement
18-
).getBoundingClientRect();
36+
);
37+
38+
if (childRect.width === 0 && childRect.height === 0) {
39+
continue;
40+
}
41+
1942
minX = Math.min(minX, childRect.left);
2043
minY = Math.min(minY, childRect.top);
2144
maxX = Math.max(maxX, childRect.right);
2245
maxY = Math.max(maxY, childRect.bottom);
2346
}
2447

25-
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
48+
return minX === Infinity
49+
? view.getBoundingClientRect()
50+
: new DOMRect(minX, minY, maxX - minX, maxY - minY);
2651
}
2752

2853
return view.getBoundingClientRect();

0 commit comments

Comments
 (0)