Skip to content

Commit bcd4b52

Browse files
authored
[Web] Drop handlers that never began from the orchestrator (#4505)
## Description A button pressed inside its element but outside a negative `hitSlop` killed every other button on the page: nothing pressed after that would begin, until the first button was pressed properly. The orchestrator records a handler on pointer-down, before the handler calls `begin()`. When the hit slop check rejects the pointer, `begin()` bails and the handler stays `UNDETERMINED`. On release it goes through `fail()`, which only acts on `BEGAN` and `ACTIVE`, so it never reaches a finished state and the orchestrator's finished-handler cleanup never drops it. Every later button then finds the stale entry in `shouldBeginWithRecordedHandlers` and cancels itself. A handler waiting for such an entry through `waitFor` would stay parked the same way. `recordHandlerIfNotPresent` now drops recorded handlers that are `UNDETERMINED` with no tracked pointers before a new gesture consults the list. That combination only describes a handler whose pointer sequence ended without a begin: every handler adds the pointer to its tracker before recording itself, so anything in flight has at least one. ## Test plan Added tests in `GestureHandler.test.ts`, the first fails without the fix. Manually, on web: a `Touchable` with `hitSlop={-40}` next to a second, plain `Touchable` with a press counter. - tap the second button: counts - tap the first button in its outer 40 px band: nothing, as expected - tap the second button again: counts, previously it was dead <details> <summary>Tested on the following code:</summary> ```tsx import React, { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Touchable } from 'react-native-gesture-handler'; // Web only: a button pressed inside its element but outside a negative hitSlop // never begins, yet it was already recorded by the orchestrator on pointer-down. // Nothing ever finishes it, so it stays recorded and every other button cancels // itself against the stale entry. Pressing the trap inside its slop clears it. const TRAP_SLOP = -40; export default function EmptyExample() { const [trap, setTrap] = useState(0); const [victim, setVictim] = useState(0); return ( <View style={styles.container}> <Text> 1. tap "victim" (counts). 2. tap the outer {-TRAP_SLOP}px band of "trap" (nothing, expected). 3. tap "victim" again: should count. </Text> <Touchable hitSlop={TRAP_SLOP} onPress={() => setTrap((n) => n + 1)} style={styles.trap}> <View style={styles.trapInner}> <Text style={styles.text}>trap: {trap}</Text> </View> </Touchable> <Touchable onPress={() => setVictim((n) => n + 1)} style={styles.victim}> <Text style={styles.text}>victim: {victim}</Text> </Touchable> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, gap: 12, }, text: { color: 'white', fontWeight: '600', }, trap: { height: 120, borderRadius: 8, backgroundColor: '#d5865b', padding: -TRAP_SLOP, }, trapInner: { flex: 1, borderRadius: 4, backgroundColor: '#b4643a', justifyContent: 'center', alignItems: 'center', }, victim: { height: 44, borderRadius: 8, backgroundColor: '#3a3a3a', justifyContent: 'center', alignItems: 'center', }, }); ``` </details>
1 parent c5db1fc commit bcd4b52

2 files changed

Lines changed: 78 additions & 1 deletion

File tree

‎packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { PointerType } from '../../../PointerType';
12
import { State } from '../../../State';
2-
import type { Config } from '../../interfaces';
3+
import type { AdaptedEvent, Config } from '../../interfaces';
4+
import { EventTypes } from '../../interfaces';
35
import EventManager from '../../tools/EventManager';
46
import type { GestureHandlerDelegate } from '../../tools/GestureHandlerDelegate';
57
import GestureHandlerOrchestrator from '../../tools/GestureHandlerOrchestrator';
@@ -112,3 +114,59 @@ describe('GestureHandler web disable mid-gesture', () => {
112114
);
113115
});
114116
});
117+
118+
describe('GestureHandlerOrchestrator idle handlers', () => {
119+
const delegate = {
120+
onEnabledChange: jest.fn(),
121+
} as unknown as GestureHandlerDelegate<unknown, IGestureHandler>;
122+
const pointerDown: AdaptedEvent = {
123+
x: 10,
124+
y: 10,
125+
offsetX: 10,
126+
offsetY: 10,
127+
pointerId: 1,
128+
eventType: EventTypes.DOWN,
129+
pointerType: PointerType.MOUSE,
130+
time: 0,
131+
};
132+
133+
afterEach(() => {
134+
(
135+
GestureHandlerOrchestrator.instance as unknown as {
136+
gestureHandlers: IGestureHandler[];
137+
}
138+
).gestureHandlers = [];
139+
});
140+
141+
test('a recorded handler that never began is dropped once its pointers are gone', () => {
142+
const stale = new TestGestureHandler(delegate);
143+
const next = new TestGestureHandler(delegate);
144+
stale.setGestureConfig({ enabled: true });
145+
next.setGestureConfig({ enabled: true });
146+
147+
GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(stale);
148+
GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(next);
149+
150+
expect(GestureHandlerOrchestrator.instance.isHandlerRecorded(stale)).toBe(
151+
false
152+
);
153+
expect(GestureHandlerOrchestrator.instance.isHandlerRecorded(next)).toBe(
154+
true
155+
);
156+
});
157+
158+
test('a recorded handler that still tracks a pointer is kept', () => {
159+
const pressed = new TestGestureHandler(delegate);
160+
const next = new TestGestureHandler(delegate);
161+
pressed.setGestureConfig({ enabled: true });
162+
next.setGestureConfig({ enabled: true });
163+
164+
pressed.tracker.addToTracker(pointerDown);
165+
GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(pressed);
166+
GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(next);
167+
168+
expect(GestureHandlerOrchestrator.instance.isHandlerRecorded(pressed)).toBe(
169+
true
170+
);
171+
});
172+
});

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,26 @@ export default class GestureHandlerOrchestrator {
274274
handler.activationIndex = this.activationIndex++;
275275
}
276276

277+
// A handler whose pointer sequence ended before it began (e.g. a press
278+
// outside a negative hit slop) never reaches a finished state, so
279+
// `cleanupFinishedHandlers` never drops it and it would block or park every
280+
// later gesture. Drop such idle entries before a new gesture consults them.
281+
private dropIdleHandlers(): void {
282+
for (let i = this.gestureHandlers.length - 1; i >= 0; --i) {
283+
const handler = this.gestureHandlers[i];
284+
285+
if (
286+
handler.state === State.UNDETERMINED &&
287+
handler.tracker.trackedPointersCount === 0
288+
) {
289+
this.removeHandlerFromOrchestrator(handler);
290+
}
291+
}
292+
}
293+
277294
public recordHandlerIfNotPresent(handler: IGestureHandler): void {
295+
this.dropIdleHandlers();
296+
278297
if (this.isHandlerRecorded(handler)) {
279298
return;
280299
}

0 commit comments

Comments
 (0)