From 44e11293bd0e25aaf82701b0419d1e050ec53af9 Mon Sep 17 00:00:00 2001 From: Priyanshu0007 Date: Sat, 26 Sep 2026 19:18:29 +0530 Subject: [PATCH 1/3] 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({ From 459e4a5cd44184731236b03457a3d50bb134e44b Mon Sep 17 00:00:00 2001 From: Priyanshu0007 Date: Sun, 1 Nov 2026 01:26:01 +0530 Subject: [PATCH 2/3] fix(button): address review comments and strengthen responder tests --- src/components/Button/Button.tsx | 4 +- .../TouchableRipple.native.tsx | 5 +- .../TouchableRipple/TouchableRipple.tsx | 4 +- src/components/__tests__/Button.test.tsx | 65 ++++++++++++++++-- .../__tests__/TouchableRipple.test.tsx | 67 +++++++++++++++---- src/core/settings.tsx | 1 - 6 files changed, 119 insertions(+), 27 deletions(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 72151e09c7..1f280dcb61 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -111,7 +111,9 @@ export type Props = Omit & { */ delayLongPress?: number; /** - * Delay in ms, from the start of the touch, before the press is engaged and ripple is triggered. + * Delay in ms, from the start of the touch, before the press feedback (ripple) is shown. + * Quick taps shorter than this delay will still fire `onPress` but might not show the ripple effect. + * Recommended values for buttons in scrollable lists are between 50ms and 100ms. */ unstable_pressDelay?: number; /** diff --git a/src/components/TouchableRipple/TouchableRipple.native.tsx b/src/components/TouchableRipple/TouchableRipple.native.tsx index b07c055757..ec7b13dd91 100644 --- a/src/components/TouchableRipple/TouchableRipple.native.tsx +++ b/src/components/TouchableRipple/TouchableRipple.native.tsx @@ -50,8 +50,7 @@ const TouchableRipple = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const { rippleEffectEnabled, ripplePressDelay } = - React.useContext(SettingsContext); + const { rippleEffectEnabled } = React.useContext(SettingsContext); const { onPress, onLongPress, onPressIn, onPressOut } = rest; @@ -91,7 +90,6 @@ const TouchableRipple = ({ return ( (SettingsContext); + const { rippleEffectEnabled } = React.useContext(SettingsContext); const { onPress, onLongPress, onPressIn, onPressOut } = rest; @@ -277,7 +276,6 @@ const TouchableRipple = ({ return ( { expect(screen.getByTestId('active-button')).toBeEnabled(); }); -it('renders button with unstable_pressDelay and triggers onPress', async () => { +const createResponderEvent = () => ({ + persist: () => {}, + nativeEvent: { timestamp: Date.now() }, + currentTarget: { + measure: (callback: any) => { + callback(0, 0, 100, 100, 0, 0); + }, + }, +}); + +it('suppresses onPressIn when touch is terminated before unstable_pressDelay', async () => { + const onPressIn = jest.fn(); const onPress = jest.fn(); await render( - ); - await userEvent.press(screen.getByTestId('delay-button')); + const button = screen.getByTestId('delay-button'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(50); + }); + await fireEvent(button, 'responderTerminate', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).not.toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); +}); + +it('triggers onPressIn after unstable_pressDelay when touch is not terminated', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn(); + + await render( + + ); + + const button = screen.getByTestId('delay-button'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + expect(onPressIn).not.toHaveBeenCalled(); + + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).toHaveBeenCalledTimes(1); + + await fireEvent(button, 'responderRelease', createResponderEvent()); expect(onPress).toHaveBeenCalledTimes(1); }); diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index c5a60d18b3..4a3026326b 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -4,8 +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 { act, fireEvent, render, screen } from '../../test-utils'; import TouchableRipple from '../TouchableRipple/TouchableRipple.native'; describe('TouchableRipple', () => { @@ -45,33 +44,73 @@ describe('TouchableRipple', () => { expect(onPress).not.toHaveBeenCalled(); }); - it('triggers onPress when unstable_pressDelay is provided', async () => { + const createResponderEvent = () => ({ + persist: () => {}, + nativeEvent: { timestamp: Date.now() }, + currentTarget: { + measure: (callback: any) => { + callback(0, 0, 100, 100, 0, 0); + }, + }, + }); + + it('suppresses onPressIn when touch is terminated before unstable_pressDelay', async () => { + const onPressIn = jest.fn(); const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + await render( - + Button ); - await userEvent.press(screen.getByText('Button')); + const button = screen.getByTestId('delay-ripple'); - expect(onPress).toHaveBeenCalledTimes(1); + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(50); + }); + await fireEvent(button, 'responderTerminate', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).not.toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); }); - it('triggers onPress when ripplePressDelay is provided in SettingsContext', async () => { + it('triggers onPressIn after unstable_pressDelay when touch is not terminated', async () => { + const onPressIn = jest.fn(); const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + await render( - - - Button - - + Button + ); - await userEvent.press(screen.getByText('Button')); + const button = screen.getByTestId('delay-ripple'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + expect(onPressIn).not.toHaveBeenCalled(); + + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).toHaveBeenCalledTimes(1); + await fireEvent(button, 'responderRelease', createResponderEvent()); expect(onPress).toHaveBeenCalledTimes(1); }); diff --git a/src/core/settings.tsx b/src/core/settings.tsx index bd86dbeaa8..4082db3b62 100644 --- a/src/core/settings.tsx +++ b/src/core/settings.tsx @@ -12,7 +12,6 @@ export type Settings = { testID, }: IconProps) => React.ReactNode; rippleEffectEnabled?: boolean; - ripplePressDelay?: number; }; export const SettingsContext = React.createContext({ From 70af55fa6af60f06a7d664f92866e0d178e6e052 Mon Sep 17 00:00:00 2001 From: Priyanshu0007 Date: Tue, 3 Nov 2026 23:09:46 +0530 Subject: [PATCH 3/3] fix(button): tag unstable_pressDelay native-only and add fast-tap test --- src/components/Button/Button.tsx | 2 +- src/components/__tests__/Button.test.tsx | 28 +++++++++++++++++++ .../__tests__/TouchableRipple.test.tsx | 28 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 1f280dcb61..5ffb407db0 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -111,7 +111,7 @@ export type Props = Omit & { */ delayLongPress?: number; /** - * Delay in ms, from the start of the touch, before the press feedback (ripple) is shown. + * Delay in ms, from the start of the touch, before the press feedback (ripple) is shown (Native-only). * Quick taps shorter than this delay will still fire `onPress` but might not show the ripple effect. * Recommended values for buttons in scrollable lists are between 50ms and 100ms. */ diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index 7490742f09..ec562cdb1d 100644 --- a/src/components/__tests__/Button.test.tsx +++ b/src/components/__tests__/Button.test.tsx @@ -168,6 +168,34 @@ it('triggers onPressIn after unstable_pressDelay when touch is not terminated', expect(onPress).toHaveBeenCalledTimes(1); }); +it('triggers onPress for fast taps shorter than unstable_pressDelay', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn(); + + await render( + + ); + + const button = screen.getByTestId('delay-button'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(60); + }); + expect(onPressIn).not.toHaveBeenCalled(); + + await fireEvent(button, 'responderRelease', createResponderEvent()); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPressIn).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 4a3026326b..bdfa9823f5 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -114,6 +114,34 @@ describe('TouchableRipple', () => { expect(onPress).toHaveBeenCalledTimes(1); }); + it('triggers onPress for fast taps shorter than unstable_pressDelay', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + + await render( + + Button + + ); + + const button = screen.getByTestId('delay-ripple'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(60); + }); + expect(onPressIn).not.toHaveBeenCalled(); + + await fireEvent(button, 'responderRelease', createResponderEvent()); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPressIn).toHaveBeenCalledTimes(1); + }); + describe('on iOS', () => { Platform.OS = 'ios';