Skip to content

Fix handling keyboard should persists taps#4158

Open
coado wants to merge 15 commits into
mainfrom
@coado/keyboardShouldPersistTaps
Open

Fix handling keyboard should persists taps#4158
coado wants to merge 15 commits into
mainfrom
@coado/keyboardShouldPersistTaps

Conversation

@coado
Copy link
Copy Markdown
Contributor

@coado coado commented May 8, 2026

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 adds onStartShouldSetResponder method that returns true (indicating that the target is willing to claim touch event) to GHPressable and to the NativeDetector whenever the single tap gesture is passed.

For the never case, 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

Copilot AI review requested due to automatic review settings May 8, 2026 09:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 onStartShouldSetResponder to v3 Pressable to claim the JS responder during touch start.
  • Add onStartShouldSetResponder to NativeDetector when the provided gesture is a single Tap gesture.
  • Add a new keyboardShouldPersistTaps test screen in apps/common-app and 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.

Comment thread packages/react-native-gesture-handler/src/v3/components/Pressable.tsx Outdated
@coado
Copy link
Copy Markdown
Contributor Author

coado commented May 8, 2026

@copilot one more round?

Copy link
Copy Markdown
Contributor

Copilot AI commented May 8, 2026

@copilot one more round?

Done — I made one more pass in 3fd544c: cleaned up the Pressable responder comment to be platform-neutral and simplified the NativeDetector responder condition without changing behavior. Re-ran yarn ts-check, yarn lint:js, and parallel validation (all clean).

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:

  • cdp.expo.dev
    • Triggering command: /usr/local/bin/node /usr/local/bin/node /home/REDACTED/work/react-native-gesture-handler/react-native-gesture-handler/apps/expo-example/node_modules/@expo/cli/build/src/utils/telemetry/clients/flushFetchDetached.js /tmp/7079ef0b9fbeebf8da523948ab565800/expo-telemetry.json (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot finished work on behalf of coado May 8, 2026 11:51
@coado coado marked this pull request as ready for review May 8, 2026 12:59
Copy link
Copy Markdown
Member

@j-piasecki j-piasecki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to integrate with the js responder here?

Comment on lines +78 to +80
onStartShouldSetResponder={
isTapGesture ? handleStartShouldSetResponder : undefined
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this conflict with #4094?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +72 to +74
const handleStartShouldSetResponder = useCallback(() => {
return !isComposedGesture(gesture) && isGestureEnabled(gesture);
}, [gesture]);
Copy link
Copy Markdown
Member

@j-piasecki j-piasecki May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you recommend in this scenario? It seems to be necessary to check if the gesture is enabled.

Comment on lines +69 to +70
const isTapGesture =
!isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure for which gestures we want it so I started with the simplest one and hoped for some discussion here 😄

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why it should be limited only to some gestures.

Copy link
Copy Markdown
Contributor Author

@coado coado May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for some gestures you may want to not have this behavior, while keeping the keyboard opened for presses only.

@coado
Copy link
Copy Markdown
Contributor Author

coado commented May 12, 2026

Do we need to integrate with the js responder here?

Discussed offline:

  • the JS responder event needs to be handled before the ScrollView becomes the responder,
  • we cannot handle the event directly in GH components, because that would prevent React Native components higher in the tree from receiving the touch in some use cases,
  • we cannot disable the JS responder handling on ScrollView and handle this natively, because that would change the behavior of keyboardShouldPersistTaps.

Instead, we add a context provider inside ScrollView. NativeDetector and RNGH Pressable mark this context from their onStartShouldSetResponder callbacks, but they do not claim the responder themselves.

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 keyboardShouldPersistTaps="handled", consumes the event before it reaches the ScrollView responder logic.

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 code
import { 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.",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we handle that in RNGH as well?

Comment on lines +55 to +101
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>
);
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep the logic related to this feature in a single place? Maybe rename JSResponderContext.ts?

Comment on lines +75 to +88
const handleStartShouldSetResponder = useCallback(() => {
const shouldHandleResponderEvent =
!isComposedGesture(gesture) && isGestureEnabled(gesture);

if (shouldHandleResponderEvent) {
const responderEventRef = jsResponderContext?.isRNGHResponderEvent;

if (responderEventRef) {
responderEventRef.current = true;
}
}

return false;
}, [gesture, jsResponderContext]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +69 to +70
const isTapGesture =
!isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tried this with an actual shared value? Did you gen any warnings?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants