Fix handling keyboard should persists taps#4158
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts RNGH v3 components to better align with React Native’s ScrollView keyboardShouldPersistTaps="handled" behavior by ensuring tap-capable children can claim the JS responder in the bubble phase, preventing ScrollView’s release-time keyboard dismissal. It also adds a new common-app demo screen to validate the behavior differences across modes.
Changes:
- Add
onStartShouldSetRespondertov3Pressableto claim the JS responder during touch start. - Add
onStartShouldSetRespondertoNativeDetectorwhen the provided gesture is a single Tap gesture. - Add a new
keyboardShouldPersistTapstest screen inapps/common-appand register it in the examples list.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx | Claims JS responder for single Tap gestures to influence keyboardShouldPersistTaps behavior. |
| packages/react-native-gesture-handler/src/v3/components/Pressable.tsx | Claims JS responder on touch start to prevent ancestor ScrollView keyboard dismissal for handled/always modes. |
| packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx | Import ordering cleanup. |
| apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx | Adds an interactive demo comparing RN vs RNGH behavior for keyboardShouldPersistTaps modes. |
| apps/common-app/src/new_api/index.tsx | Registers the new demo screen in the examples list. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@copilot one more round? |
Done — I made one more pass in Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
j-piasecki
left a comment
There was a problem hiding this comment.
Do we need to integrate with the js responder here?
| onStartShouldSetResponder={ | ||
| isTapGesture ? handleStartShouldSetResponder : undefined | ||
| } |
There was a problem hiding this comment.
Shouldn't because for cancelsJSResponder to do anything the gesture has to be recognized first and onStartShouldSetResponder is invoked before that happens. Will verify that though.
| const handleStartShouldSetResponder = useCallback(() => { | ||
| return !isComposedGesture(gesture) && isGestureEnabled(gesture); | ||
| }, [gesture]); |
There was a problem hiding this comment.
This will not react to changes if a shared value is passed to the config. Also, I think reading the value of SVs on the JS thread will result in a warning.
There was a problem hiding this comment.
What would you recommend in this scenario? It seems to be necessary to check if the gesture is enabled.
| const isTapGesture = | ||
| !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; |
There was a problem hiding this comment.
Why restrict it only to a tap gesture? And why skip composed gestures?
Even if we only allow tap, then tap + double tap should be fine.
There was a problem hiding this comment.
I wasn't sure for which gestures we want it so I started with the simplest one and hoped for some discussion here 😄
There was a problem hiding this comment.
I don't see why it should be limited only to some gestures.
There was a problem hiding this comment.
I think for some gestures you may want to not have this behavior, while keeping the keyboard opened for presses only.
Discussed offline:
Instead, we add a context provider inside If there is a React Native responder target between the GH component and the ScrollView, it can still handle the event first. Otherwise, the event continues bubbling until it reaches the dummy responder component rendered directly inside the ScrollView. That component checks the context marker and, for The solution was measured by rendering 500 gesture detectors and 500 Pressables on gesture handler with and without this change. No regression has been observed. Benchmark codeimport { render } from '@testing-library/react-native';
import { act } from 'react';
import GestureHandlerRootView from '../components/GestureHandlerRootView';
import { Pressable, ScrollView } from '../v3/components';
import { GestureDetector } from '../v3/detectors';
import { useTapGesture } from '../v3/hooks/gestures';
declare const process: {
env: Record<string, string | undefined>;
};
const COUNT = Number(process.env.RNGH_PERF_COUNT ?? 500);
const WARMUP = Number(process.env.RNGH_PERF_WARMUP ?? 10);
const SAMPLES = Number(process.env.RNGH_PERF_SAMPLES ?? 50);
const VARIANT = process.env.RNGH_PERF_VARIANT ?? 'unknown';
const flushImmediate = () =>
new Promise((resolve) => {
setImmediate(() => resolve(undefined));
});
const now = () => performance.now();
function TapItem() {
const tap = useTapGesture({ disableReanimated: true });
return <GestureDetector gesture={tap} />;
}
function ManyGestureDetectors() {
return (
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="handled">
{Array.from({ length: COUNT }, (_, index) => (
<TapItem key={`detector-${index}`} />
))}
{Array.from({ length: COUNT }, (_, index) => (
<Pressable key={`pressable-${index}`} />
))}
</ScrollView>
</GestureHandlerRootView>
);
}
function percentile(sortedValues: number[], percent: number) {
const index = (sortedValues.length - 1) * percent;
const lower = Math.floor(index);
const upper = Math.ceil(index);
if (lower === upper) {
return sortedValues[lower];
}
return (
sortedValues[lower] +
(sortedValues[upper] - sortedValues[lower]) * (index - lower)
);
}
function stats(values: number[]) {
const sorted = [...values].sort((a, b) => a - b);
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
const variance =
values.reduce((sum, value) => sum + (value - mean) ** 2, 0) /
(values.length - 1);
return {
count: values.length,
mean,
median: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95),
min: sorted[0],
max: sorted[sorted.length - 1],
sd: Math.sqrt(variance),
};
}
describe('RNGH context mount performance', () => {
test('mounts hundreds of GestureDetectors inside ScrollView', async () => {
const measurements: number[] = [];
for (let iteration = 0; iteration < WARMUP + SAMPLES; iteration += 1) {
const startedAt = now();
const screen = render(<ManyGestureDetectors />);
await act(flushImmediate);
const duration = now() - startedAt;
screen.unmount();
if (iteration >= WARMUP) {
measurements.push(duration);
}
}
console.log(
'RNGH_CONTEXT_PERF_RESULT ' +
JSON.stringify({
variant: VARIANT,
gestureDetectors: COUNT,
pressables: COUNT,
warmup: WARMUP,
samples: SAMPLES,
mountMs: stats(measurements),
})
);
});
}); |
|
|
||
| const MODE_DESCRIPTIONS: Record<Mode, string> = { | ||
| never: | ||
| "RN: first tap outside the input dismisses the keyboard AND is swallowed — press doesn't fire. GH: keyboard still dismisses (ScrollView captures the responder normally), but the press ALSO fires because GH's native recognizer runs in parallel to the JS responder system.", |
There was a problem hiding this comment.
Can we handle that in RNGH as well?
| type GHScrollViewResponderInterceptorProps = PropsWithChildren<{ | ||
| keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; | ||
| }>; | ||
|
|
||
| const GHScrollViewResponderInterceptor = ({ | ||
| children, | ||
| keyboardShouldPersistTaps, | ||
| }: GHScrollViewResponderInterceptorProps) => { | ||
| const isRNGHResponderEvent = useRef(false); | ||
| const contextValue = useMemo( | ||
| () => ({ isRNGHResponderEvent }), | ||
| [isRNGHResponderEvent] | ||
| ); | ||
|
|
||
| const resetRNGHResponderEvent = useCallback(() => { | ||
| isRNGHResponderEvent.current = false; | ||
| return false; | ||
| }, []); | ||
|
|
||
| const handleStartShouldSetResponder = useCallback(() => { | ||
| const shouldHandleRNGHEvent = | ||
| keyboardShouldPersistTaps === 'handled' && isRNGHResponderEvent.current; | ||
|
|
||
| isRNGHResponderEvent.current = false; | ||
|
|
||
| return shouldHandleRNGHEvent; | ||
| }, [keyboardShouldPersistTaps]); | ||
|
|
||
| // RNGH tap responders need to let RN components higher in the tree handle | ||
| // the JS responder event first. If no RN component claims it, this logical | ||
| // ScrollView child consumes the marked event before ScrollView's own | ||
| // keyboardShouldPersistTaps='handled' responder logic handles it. | ||
| // For more information check this comment: | ||
| // https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964 | ||
| return ( | ||
| <JSResponderContext value={contextValue}> | ||
| <View | ||
| collapsable={false} | ||
| onStartShouldSetResponderCapture={resetRNGHResponderEvent} | ||
| onStartShouldSetResponder={handleStartShouldSetResponder} | ||
| pointerEvents="box-none" | ||
| style={styles.logicalResponder}> | ||
| {children} | ||
| </View> | ||
| </JSResponderContext> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Can we keep the logic related to this feature in a single place? Maybe rename JSResponderContext.ts?
| const handleStartShouldSetResponder = useCallback(() => { | ||
| const shouldHandleResponderEvent = | ||
| !isComposedGesture(gesture) && isGestureEnabled(gesture); | ||
|
|
||
| if (shouldHandleResponderEvent) { | ||
| const responderEventRef = jsResponderContext?.isRNGHResponderEvent; | ||
|
|
||
| if (responderEventRef) { | ||
| responderEventRef.current = true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| }, [gesture, jsResponderContext]); |
There was a problem hiding this comment.
This can also be extracted to a single hook, along with a call to use(JSResponderContext);
Also, can it be updated so that if called outside of the context, it returns some stable no-op function to avoid allocation?
| const isTapGesture = | ||
| !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; |
There was a problem hiding this comment.
I don't see why it should be limited only to some gestures.
| THandlerData, | ||
| TExtendedHandlerData extends THandlerData, | ||
| >(gesture: SingleGesture<TConfig, THandlerData, TExtendedHandlerData>) { | ||
| return maybeUnpackValue(gesture.config.enabled) !== false; |
There was a problem hiding this comment.
Have you tried this with an actual shared value? Did you gen any warnings?
Description
The
keyboardShouldPersistTaps="handled"on the ScrollView persists the keyboard if there is a target that could claim the JS responder event in the bubble phase (bottom-up). The diff addsonStartShouldSetRespondermethod that returnstrue(indicating that the target is willing to claim touch event) toGHPressableand to theNativeDetectorwhenever the single tap gesture is passed.For the
nevercase, the keyboard is always dropped by the touch event captured by the React Native ScrollView. This is done on the JS side, hence the touch is still handled by the Gesture Handler Pressable, which is opposite to the React Native Pressable for which the touch dismissing the keyboard is dropped.Test plan
keyboardShouldPersist-video.mov