From 4bfef632dfd5cc6268c92996291e9c8f1e6f3330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 10 Aug 2026 13:24:26 +0200 Subject: [PATCH 1/2] Add new ScrollView prop --- .../Components/ScrollView/ScrollView.d.ts | 10 ++ .../Components/ScrollView/ScrollView.js | 10 +- ...ollView-shouldDismissKeyboardOnTap-test.js | 142 ++++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 27 ++-- 4 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts index 6d9a90b3d5a9..2f93fb8c50eb 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts @@ -14,6 +14,7 @@ import {HostInstance} from '../../../types/public/ReactNativeTypes'; import {ColorValue, StyleProp} from '../../StyleSheet/StyleSheet'; import {ViewStyle} from '../../StyleSheet/StyleSheetTypes'; import { + GestureResponderEvent, NativeSyntheticEvent, NativeTouchEvent, } from '../../Types/CoreEventTypes'; @@ -665,6 +666,15 @@ export interface ScrollViewProps */ keyboardShouldPersistTaps?: 'always' | 'never' | 'handled' | undefined; + /** + * Called when a tap would dismiss the keyboard in + * `keyboardShouldPersistTaps="handled"` mode. Components that handle + * touches outside of the responder system can return `false` to mark the + * tap as handled and keep the keyboard up. + */ + shouldDismissKeyboardOnTap?: + ((event: GestureResponderEvent) => boolean) | undefined; + /** * Called when scrollable content view of the ScrollView changes. * Handler function is passed the content width and content height as parameters: (contentWidth, contentHeight) diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 26b01e15f3eb..14c3d43b492d 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -583,6 +583,13 @@ type ScrollViewBaseProps = Readonly<{ * @default `'never'` */ keyboardShouldPersistTaps?: ?('always' | 'never' | 'handled'), + /** + * Called when a tap would dismiss the keyboard in + * `keyboardShouldPersistTaps="handled"` mode. Components that handle + * touches outside of the responder system can return `false` to mark the + * tap as handled and keep the keyboard up. + */ + shouldDismissKeyboardOnTap?: ?(event: GestureResponderEvent) => boolean, /** * When set, the scroll view will adjust the scroll position so that the first child that is * partially or fully visible and at or beyond `minIndexForVisible` will not change position. @@ -1545,7 +1552,8 @@ class ScrollView extends React.Component { if ( this.props.keyboardShouldPersistTaps === 'handled' && this._keyboardIsDismissible() && - e.target !== currentlyFocusedInput + e.target !== currentlyFocusedInput && + this.props.shouldDismissKeyboardOnTap?.(e) !== false ) { return true; } diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js new file mode 100644 index 000000000000..3ee1afc2b9e7 --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js @@ -0,0 +1,142 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {HostInstance} from '../../../../src/private/types/HostInstance'; +import type {KeyboardEvent} from '../../Keyboard/Keyboard'; +import type {ScrollViewProps} from '../ScrollView'; + +import TextInputState from '../../TextInput/TextInputState'; +import * as React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; + +// The jest preset replaces ScrollView with a mock component — these tests +// exercise the real implementation's responder negotiation. +const ScrollView = (jest.requireActual('../ScrollView') as $FlowFixMe).default; + +const fakeTextInput: HostInstance = {} as $FlowFixMe; +const fakeKeyboardEvent: KeyboardEvent = { + duration: 250, + easing: 'keyboard', + endCoordinates: {height: 336, screenX: 0, screenY: 400, width: 400}, + startCoordinates: {height: 0, screenX: 0, screenY: 736, width: 400}, + isEventFromThisApp: true, +}; + +function fakeTapEvent(target: unknown) { + return {target, nativeEvent: {touches: []}} as $FlowFixMe; +} + +async function renderScrollView(props: ScrollViewProps) { + let testRenderer; + await ReactTestRenderer.act(() => { + testRenderer = ReactTestRenderer.create(); + }); + + const instance = (testRenderer as $FlowFixMe).root.find( + node => node.instance?._handleStartShouldSetResponder != null, + ).instance as $FlowFixMe; + + return instance; +} + +describe('shouldDismissKeyboardOnTap', () => { + beforeEach(() => { + // Simulate a focused text input with an open soft keyboard. + TextInputState.registerInput(fakeTextInput); + TextInputState.focusInput(fakeTextInput); + }); + + afterEach(() => { + TextInputState.blurInput(fakeTextInput); + TextInputState.unregisterInput(fakeTextInput); + }); + + it('by default, a tap outside the focused input claims the responder in "handled" mode', async () => { + const instance = await renderScrollView({ + keyboardShouldPersistTaps: 'handled', + }); + instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent); + + expect(instance._handleStartShouldSetResponder(fakeTapEvent({}))).toBe( + true, + ); + }); + + it('returning false vetoes the responder claim', async () => { + const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false); + const instance = await renderScrollView({ + keyboardShouldPersistTaps: 'handled', + shouldDismissKeyboardOnTap, + }); + instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent); + + const tapEvent = fakeTapEvent({}); + expect(instance._handleStartShouldSetResponder(tapEvent)).toBe(false); + expect(shouldDismissKeyboardOnTap).toHaveBeenCalledWith(tapEvent); + }); + + it('returning true keeps the responder claim', async () => { + const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(true); + const instance = await renderScrollView({ + keyboardShouldPersistTaps: 'handled', + shouldDismissKeyboardOnTap, + }); + instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent); + + const tapEvent = fakeTapEvent({}); + expect(instance._handleStartShouldSetResponder(tapEvent)).toBe(true); + expect(shouldDismissKeyboardOnTap).toHaveBeenCalledWith(tapEvent); + }); + + it('is not consulted when there is no dismissible keyboard', async () => { + const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false); + const instance = await renderScrollView({ + keyboardShouldPersistTaps: 'handled', + shouldDismissKeyboardOnTap, + }); + // No keyboard event was received, so there is no keyboard to dismiss. + + expect(instance._handleStartShouldSetResponder(fakeTapEvent({}))).toBe( + false, + ); + expect(shouldDismissKeyboardOnTap).not.toHaveBeenCalled(); + }); + + it('is not consulted when the tap lands on the focused input', async () => { + const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false); + const instance = await renderScrollView({ + keyboardShouldPersistTaps: 'handled', + shouldDismissKeyboardOnTap, + }); + instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent); + + expect( + instance._handleStartShouldSetResponder(fakeTapEvent(fakeTextInput)), + ).toBe(false); + expect(shouldDismissKeyboardOnTap).not.toHaveBeenCalled(); + }); + + it.each(['never', 'always'])( + 'is not consulted when keyboardShouldPersistTaps is %s', + async keyboardShouldPersistTaps => { + const shouldDismissKeyboardOnTap = jest.fn().mockReturnValue(false); + const instance = await renderScrollView({ + keyboardShouldPersistTaps, + shouldDismissKeyboardOnTap, + }); + instance.scrollResponderKeyboardWillShow(fakeKeyboardEvent); + + expect(instance._handleStartShouldSetResponder(fakeTapEvent({}))).toBe( + false, + ); + expect(shouldDismissKeyboardOnTap).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 1bef75be88ab..86024e5b2860 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<040a8eb579cea089295c7137db40e0d6>> + * @generated SignedSource<<72f33a1c654fc86a6cc2e8b917ba5f8f>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -4256,6 +4256,9 @@ declare type ScrollViewBaseProps = { readonly scrollEnabled?: boolean readonly scrollEventThrottle?: number readonly scrollViewRef?: React.Ref + readonly shouldDismissKeyboardOnTap?: ( + event: GestureResponderEvent, + ) => boolean readonly showsVerticalScrollIndicator?: boolean readonly snapToAlignment?: "center" | "end" | "start" readonly snapToEnd?: boolean @@ -5722,7 +5725,7 @@ export { AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 4bf77b70 + Animated, // 75c41a48 AppConfig, // 35c0ca70 AppRegistry, // 1e8c5a00 AppState, // 12012be5 @@ -5774,9 +5777,9 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // e1b005c7 - FlatListInstance, // 2d1d8e45 - FlatListProps, // 94fa2dc7 + FlatList, // f9c45fc6 + FlatListInstance, // b3da8d34 + FlatListProps, // 52d81a96 FocusEvent, // 850f1517 FontVariant, // 7c7558bb GestureResponderEvent, // 14d3e77a @@ -5917,18 +5920,18 @@ export { ScrollEvent, // d7abdd0a ScrollResponderType, // ba188eae ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // 066a8597 + ScrollView, // 181e1e41 ScrollViewImperativeMethods, // 904c66fd ScrollViewInstance, // ccf4f341 - ScrollViewProps, // b62913d1 + ScrollViewProps, // 2bcb6875 ScrollViewPropsAndroid, // 02f3df2e ScrollViewPropsIOS, // 807cb4f6 ScrollViewScrollToOptions, // 3313411e SectionBase, // 9f13db00 - SectionList, // ee3e7972 + SectionList, // 728d3fe6 SectionListData, // 1a4de01a - SectionListInstance, // c9b991fe - SectionListProps, // 0e933318 + SectionListInstance, // 10da5c15 + SectionListProps, // 4f5adad4 SectionListRenderItem, // 715b2086 SectionListRenderItemInfo, // 4a48a922 Separators, // 6a45f7e3 @@ -6001,10 +6004,10 @@ export { VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e VirtualizedListInstance, // 423ee7c0 - VirtualizedListProps, // f51f4a42 + VirtualizedListProps, // 3e8bcf6f VirtualizedSectionList, // 9fd9cd61 VirtualizedSectionListInstance, // 12b706d5 - VirtualizedSectionListProps, // 8210f30a + VirtualizedSectionListProps, // d51515e0 WrapperComponentProvider, // 9ef54e61 codegenNativeCommands, // 628a7c0a codegenNativeComponent, // 32a1bca6 From 75033b7c85168079ebcb9968838eaab53b549de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 10 Aug 2026 13:56:53 +0200 Subject: [PATCH 2/2] Copilot review --- .../ScrollView-shouldDismissKeyboardOnTap-test.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js index 3ee1afc2b9e7..71ff592d40db 100644 --- a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-shouldDismissKeyboardOnTap-test.js @@ -33,13 +33,14 @@ function fakeTapEvent(target: unknown) { return {target, nativeEvent: {touches: []}} as $FlowFixMe; } +let testRenderer: $FlowFixMe = null; + async function renderScrollView(props: ScrollViewProps) { - let testRenderer; await ReactTestRenderer.act(() => { testRenderer = ReactTestRenderer.create(); }); - const instance = (testRenderer as $FlowFixMe).root.find( + const instance = testRenderer.root.find( node => node.instance?._handleStartShouldSetResponder != null, ).instance as $FlowFixMe; @@ -53,7 +54,14 @@ describe('shouldDismissKeyboardOnTap', () => { TextInputState.focusInput(fakeTextInput); }); - afterEach(() => { + afterEach(async () => { + if (testRenderer != null) { + await ReactTestRenderer.act(() => { + testRenderer.unmount(); + }); + testRenderer = null; + } + TextInputState.blurInput(fakeTextInput); TextInputState.unregisterInput(fakeTextInput); });