From d87706995454c73a53b2af5ea0c54f1f801152c7 Mon Sep 17 00:00:00 2001 From: Priyanshu0007 Date: Sat, 26 Sep 2026 19:18:29 +0530 Subject: [PATCH] fix(button): prevent false ripples during scroll deceleration (#4911) --- src/components/Button/Button.tsx | 6 ++++ .../TouchableRipple.native.tsx | 5 ++- .../TouchableRipple/TouchableRipple.tsx | 4 ++- src/components/__tests__/Button.test.tsx | 13 ++++++++ .../__tests__/TouchableRipple.test.tsx | 31 +++++++++++++++++++ src/core/settings.tsx | 1 + 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6f07113572..72151e09c7 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -110,6 +110,10 @@ export type Props = Omit & { * The number of milliseconds a user must touch the element before executing `onLongPress`. */ delayLongPress?: number; + /** + * Delay in ms, from the start of the touch, before the press is engaged and ripple is triggered. + */ + unstable_pressDelay?: number; /** * Style of button's inner content. * Use this prop to apply custom height and width, to set a custom padding or to set the icon on the right with `flexDirection: 'row-reverse'`. @@ -179,6 +183,7 @@ const Button = ({ onPressOut, onLongPress, delayLongPress, + unstable_pressDelay, style, theme: themeOverrides, uppercase: uppercaseProp, @@ -327,6 +332,7 @@ const Button = ({ onPressIn={hasPassedTouchHandler ? handlePressIn : undefined} onPressOut={hasPassedTouchHandler ? handlePressOut : undefined} delayLongPress={delayLongPress} + unstable_pressDelay={unstable_pressDelay} aria-label={ariaLabel} accessibilityHint={accessibilityHint} role={role} diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index ec7b13dd91..b07c055757 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -50,7 +50,8 @@ const TouchableRipple = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const { rippleEffectEnabled } = React.useContext(SettingsContext); + const { rippleEffectEnabled, ripplePressDelay } = + React.useContext(SettingsContext); const { onPress, onLongPress, onPressIn, onPressOut } = rest; @@ -90,6 +91,7 @@ const TouchableRipple = ({ return ( (SettingsContext); + const { rippleEffectEnabled, ripplePressDelay } = + React.useContext(SettingsContext); const { onPress, onLongPress, onPressIn, onPressOut } = rest; @@ -276,6 +277,7 @@ const TouchableRipple = ({ return ( { expect(screen.getByTestId('active-button')).toBeEnabled(); }); +it('renders button with unstable_pressDelay and triggers onPress', async () => { + const onPress = jest.fn(); + + await render( + + ); + + await userEvent.press(screen.getByTestId('delay-button')); + expect(onPress).toHaveBeenCalledTimes(1); +}); + it('renders button with color', async () => { const tree = ( await render() diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index 663d540bec..c5a60d18b3 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -4,6 +4,7 @@ import type { GestureResponderEvent } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/react-native'; +import { SettingsContext } from '../../core/settings'; import { render, screen } from '../../test-utils'; import TouchableRipple from '../TouchableRipple/TouchableRipple.native'; @@ -44,6 +45,36 @@ describe('TouchableRipple', () => { expect(onPress).not.toHaveBeenCalled(); }); + it('triggers onPress when unstable_pressDelay is provided', async () => { + const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + await render( + + Button + + ); + + await userEvent.press(screen.getByText('Button')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('triggers onPress when ripplePressDelay is provided in SettingsContext', async () => { + const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + await render( + + + Button + + + ); + + await userEvent.press(screen.getByText('Button')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + describe('on iOS', () => { Platform.OS = 'ios'; diff --git a/src/core/settings.tsx b/src/core/settings.tsx index 4082db3b62..bd86dbeaa8 100644 --- a/src/core/settings.tsx +++ b/src/core/settings.tsx @@ -12,6 +12,7 @@ export type Settings = { testID, }: IconProps) => React.ReactNode; rippleEffectEnabled?: boolean; + ripplePressDelay?: number; }; export const SettingsContext = React.createContext({