Skip to content

Commit c5db1fc

Browse files
authored
[Web] Drop a handler from the orchestrator when it gets disabled mid-gesture (#4504)
## Description Disabling a `Touchable`/`Pressable` while a press was in progress killed every other button on the page: nothing pressed after that would begin. When a handler is disabled mid-gesture, `updateGestureConfig` cancels it and `moveToState` immediately resets the disabled handler back to `UNDETERMINED`. The orchestrator's finished-handler cleanup runs later in a microtask and only drops handlers whose state is finished, so it skipped this one and left it recorded. Every later button consulted that stale entry in `shouldBeginWithRecordedHandlers` and cancelled itself. `updateGestureConfig` now resets the handler and removes it from the orchestrator itself after the `cancel`/`fail`. A handler that never began still skips the cancel, as before, and is just reset and unrecorded. The web button also resets its press visuals instantly instead of running the press-out transition when the cancel comes from the disable itself: no pointer lifted, and a disable usually restyles the button in the same frame, so the fade showed as a brief flash. ## Test plan Added a test in `GestureHandler.test.ts` that fails without the fix. Manually: a `Touchable` that disables itself shortly after `onPressIn`, e.g. a 700 ms timeout, held past that point, next to a second `Touchable` that re-enables it. - `onPressOut` fires when the button gets disabled and `onPress` does not fire on release - the second button still works afterwards - after re-enabling, the first button works again - the button snaps to its resting look on disable, with no flash <details> <summary>Tested on the following code:</summary> ```tsx import React, { useCallback, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Touchable } from 'react-native-gesture-handler'; // A button that gets disabled while a press is in progress. // Press and hold the blue box. After `DISABLE_AFTER_MS` it disables itself. // Expected: onPressOut fires at that moment, onPress never fires on release, // and the press animation resets instead of staying stuck (macOS especially). const DISABLE_AFTER_MS = 700; export default function EmptyExample() { const [disabled, setDisabled] = useState(false); const [log, setLog] = useState<string[]>([]); const timer = useRef<ReturnType<typeof setTimeout> | null>(null); const push = useCallback((entry: string) => { console.log(`RNGH-mid-press: ${entry}`); setLog((l) => [...l.slice(-7), entry]); }, []); const onPressIn = useCallback(() => { push('onPressIn'); timer.current = setTimeout(() => { push(`disabled after ${DISABLE_AFTER_MS}ms`); setDisabled(true); }, DISABLE_AFTER_MS); }, [push]); const onPressOut = useCallback(() => { push('onPressOut'); if (timer.current) { clearTimeout(timer.current); timer.current = null; } }, [push]); const onPress = useCallback(() => push('onPress'), [push]); const reset = useCallback(() => { setDisabled(false); setLog([]); }, []); return ( <View style={styles.container}> <Text> Hold the box for longer than {DISABLE_AFTER_MS}ms. It disables itself mid-press. </Text> <Touchable disabled={disabled} onPressIn={onPressIn} onPressOut={onPressOut} onPress={onPress} activeOpacity={0.4} style={[styles.box, disabled ? styles.boxDisabled : styles.boxEnabled]}> <Text style={styles.boxText}>{disabled ? 'disabled' : 'hold me'}</Text> </Touchable> <Touchable onPress={reset} style={styles.reset}> <Text style={styles.boxText}>re-enable and clear log</Text> </Touchable> {log.map((entry, i) => ( <Text key={i} style={styles.log}> {entry} </Text> ))} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, padding: 16, gap: 12, }, box: { height: 100, borderRadius: 8, justifyContent: 'center', alignItems: 'center', }, boxEnabled: { backgroundColor: '#5b7bd5', }, boxDisabled: { backgroundColor: '#9a9a9a', }, boxText: { color: 'white', fontWeight: '600', }, reset: { height: 44, borderRadius: 8, backgroundColor: '#3a3a3a', justifyContent: 'center', alignItems: 'center', }, log: { fontVariant: ['tabular-nums'], }, }); ``` </details>
1 parent eabe157 commit c5db1fc

4 files changed

Lines changed: 62 additions & 12 deletions

File tree

‎packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,12 @@ export const ButtonComponent = ({
558558
: defaultScale;
559559

560560
const easing = 'cubic-bezier(0.5, 1, 0.89, 1)';
561-
const effectiveDuration = prefersReducedMotion() ? 0 : currentDuration;
561+
// A disabled button snaps: a cancel that comes from the disable itself is not
562+
// a lifted pointer, and fading it would overlap the restyle that usually
563+
// comes with disabling and show as a flash. Re-enabling keeps the last
564+
// duration, so a hover that resumes still animates.
565+
const effectiveDuration =
566+
prefersReducedMotion() || !enabled ? 0 : currentDuration;
562567
const transitionProps: string[] = [];
563568
if (hasOpacity) {
564569
transitionProps.push(`opacity ${effectiveDuration}ms ${easing}`);

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -861,17 +861,19 @@ export default abstract class GestureHandler implements IGestureHandler {
861861
return;
862862
}
863863

864-
switch (this.state) {
865-
case State.ACTIVE:
866-
this.fail(true);
867-
break;
868-
case State.UNDETERMINED:
869-
GestureHandlerOrchestrator.instance.removeHandlerFromOrchestrator(this);
870-
break;
871-
default:
872-
this.cancel(true);
873-
break;
874-
}
864+
if (this.state === State.ACTIVE) {
865+
this.fail(true);
866+
} else if (this.state !== State.UNDETERMINED) {
867+
// `cancel` also fires from UNDETERMINED. Most disables hit a handler
868+
// that never began (e.g. a button created disabled), and running the
869+
// cancel pipeline for those only produces cancel side effects.
870+
this.cancel(true);
871+
}
872+
873+
// The cancel/fail above lands in `moveToState`, which resets a disabled
874+
// handler to UNDETERMINED right away. The orchestrator's finished-handler
875+
// cleanup would then skip it and leave it recorded, so drop it now.
876+
GestureHandlerOrchestrator.instance.dropHandler(this);
875877
}
876878

877879
private checkHitSlop(): boolean {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { State } from '../../../State';
12
import type { Config } from '../../interfaces';
23
import EventManager from '../../tools/EventManager';
34
import type { GestureHandlerDelegate } from '../../tools/GestureHandlerDelegate';
5+
import GestureHandlerOrchestrator from '../../tools/GestureHandlerOrchestrator';
46
import { GestureHandlerWebDelegate } from '../../tools/GestureHandlerWebDelegate';
57
import GestureHandler from '../GestureHandler';
68
import type IGestureHandler from '../IGestureHandler';
@@ -19,6 +21,7 @@ class TestEventManager extends EventManager<unknown> {
1921
function createHandler(enabled: boolean) {
2022
const delegate = {
2123
onEnabledChange: jest.fn(),
24+
reset: jest.fn(),
2225
} as unknown as GestureHandlerDelegate<unknown, IGestureHandler>;
2326
const handler = new TestGestureHandler(delegate);
2427

@@ -40,6 +43,7 @@ describe('GestureHandler web config reset', () => {
4043
const delegate = {
4144
onEnabledChange: jest.fn(),
4245
updateDOM: jest.fn(),
46+
reset: jest.fn(),
4347
};
4448
const handler = new TestGestureHandler(
4549
delegate as unknown as GestureHandlerDelegate<unknown, IGestureHandler>
@@ -74,3 +78,37 @@ describe('GestureHandler web event manager attachment', () => {
7478
expect(manager.registerListeners).not.toHaveBeenCalled();
7579
});
7680
});
81+
82+
describe('GestureHandler web disable mid-gesture', () => {
83+
afterEach(() => {
84+
// The orchestrator is a singleton, drop handlers recorded by the test.
85+
(
86+
GestureHandlerOrchestrator.instance as unknown as {
87+
gestureHandlers: IGestureHandler[];
88+
}
89+
).gestureHandlers = [];
90+
});
91+
92+
test('a handler disabled while begun is dropped from the orchestrator', () => {
93+
const delegate = {
94+
onEnabledChange: jest.fn(),
95+
onCancel: jest.fn(),
96+
reset: jest.fn(),
97+
} as unknown as GestureHandlerDelegate<unknown, IGestureHandler>;
98+
const handler = new TestGestureHandler(delegate);
99+
handler.setGestureConfig({ enabled: true });
100+
// The full event pipeline is not under test, silence event emission.
101+
handler.sendEvent = jest.fn();
102+
103+
GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(handler);
104+
handler.begin();
105+
expect(handler.state).toBe(State.BEGAN);
106+
107+
handler.setGestureConfig({ enabled: false });
108+
109+
expect(handler.state).toBe(State.UNDETERMINED);
110+
expect(GestureHandlerOrchestrator.instance.isHandlerRecorded(handler)).toBe(
111+
false
112+
);
113+
});
114+
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export default class GestureHandlerOrchestrator {
3232
handler.activationIndex = Number.MAX_VALUE;
3333
}
3434

35+
public dropHandler(handler: IGestureHandler): void {
36+
this.cleanHandler(handler);
37+
this.removeHandlerFromOrchestrator(handler);
38+
}
39+
3540
public isHandlerRecorded(handler: IGestureHandler): boolean {
3641
return this.gestureHandlers.includes(handler);
3742
}

0 commit comments

Comments
 (0)