Skip to content

Implement Pressable based on Touchable - #4411

Open
m-bert wants to merge 8 commits into
mainfrom
@mbert/pressable-wrapper
Open

Implement Pressable based on Touchable#4411
m-bert wants to merge 8 commits into
mainfrom
@mbert/pressable-wrapper

Conversation

@m-bert

@m-bert m-bert commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR splits Pressable into two components - current Pressable with state machine and new Pressable based on Touchable. Public API remains unchanged - the split is internal. Pressable based on Touchable is chosen by default. Old implementation is used only when relation properties are passed.

Test plan

Added an example under new_api > Tests > "Pressable engines (Touchable vs Stateful)" that renders both engines side by side with identical props and logs the callback order. Toggle each prop and confirm both columns behave the same.

Example code
import React, { useCallback, useRef, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import type { PressableProps } from 'react-native-gesture-handler/src/components/Pressable/PressableProps';
// Internal engines imported directly so the two implementations can be compared
// side by side, independent of the public `Pressable` wrapper's routing.
import PressableWithTouchable from 'react-native-gesture-handler/src/v3/components/PressableWithTouchable';
import StatefulPressable from 'react-native-gesture-handler/src/v3/components/StatefulPressable';

type Engine = {
  key: string;
  label: string;
  short: string;
  Component: React.ComponentType<PressableProps>;
};

const ENGINES: Engine[] = [
  {
    key: 'stateful',
    label: 'Stateful\n(relation props)',
    short: 'STATEFUL',
    Component: StatefulPressable,
  },
  {
    key: 'touchable',
    label: 'Touchable\n(default)',
    short: 'TOUCHABLE',
    Component: PressableWithTouchable,
  },
];

type Toggle = {
  label: string;
  value: boolean;
  onChange: (value: boolean) => void;
};

const ToggleRow = ({ label, value, onChange }: Toggle) => (
  <View style={styles.toggleRow}>
    <Text style={styles.toggleLabel}>{label}</Text>
    <Switch value={value} onValueChange={onChange} />
  </View>
);

export default function PressableTouchableExample() {
  const [log, setLog] = useState<string[]>([]);
  const counter = useRef(0);

  const [pressDelay, setPressDelay] = useState(false);
  const [longPress, setLongPress] = useState(true);
  const [disabled, setDisabled] = useState(false);
  const [hitSlop, setHitSlop] = useState(false);
  const [retention, setRetention] = useState(false);

  const addLog = useCallback((engine: string, name: string) => {
    // Capture the sequence number here, not inside the (deferred, batched)
    // setLog updater — otherwise several updaters read the same later value.
    const seq = (counter.current += 1);
    setLog((prev) => [`${seq}. [${engine}] ${name}`, ...prev].slice(0, 60));
  }, []);

  const clearLog = useCallback(() => {
    counter.current = 0;
    setLog([]);
  }, []);

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <Text style={styles.hint}>
        Press each box and compare the callback order in the log. Both columns
        get identical props — the left runs the state-machine engine, the right
        the native-button Touchable engine.
      </Text>

      <View style={styles.controls}>
        <ToggleRow
          label="unstable_pressDelay (300ms)"
          value={pressDelay}
          onChange={setPressDelay}
        />
        <ToggleRow
          label="onLongPress"
          value={longPress}
          onChange={setLongPress}
        />
        <ToggleRow label="disabled" value={disabled} onChange={setDisabled} />
        <ToggleRow label="hitSlop (20)" value={hitSlop} onChange={setHitSlop} />
        <ToggleRow
          label="pressRetentionOffset (40)"
          value={retention}
          onChange={setRetention}
        />
      </View>

      <View style={styles.columns}>
        {ENGINES.map((engine) => {
          const { Component } = engine;
          return (
            <View key={engine.key} style={styles.column}>
              <Text style={styles.columnTitle}>{engine.label}</Text>
              <Component
                disabled={disabled}
                unstable_pressDelay={pressDelay ? 300 : undefined}
                delayLongPress={500}
                hitSlop={hitSlop ? 20 : undefined}
                pressRetentionOffset={retention ? 40 : undefined}
                android_ripple={{ color: '#ffffff55' }}
                onPressIn={() => addLog(engine.short, 'onPressIn')}
                onPressOut={() => addLog(engine.short, 'onPressOut')}
                onPress={() => addLog(engine.short, 'onPress')}
                onLongPress={
                  longPress
                    ? () => addLog(engine.short, 'onLongPress')
                    : undefined
                }
                onHoverIn={() => addLog(engine.short, 'onHoverIn')}
                onHoverOut={() => addLog(engine.short, 'onHoverOut')}
                style={({ pressed }) => [
                  styles.box,
                  { backgroundColor: pressed ? '#2e7d32' : '#546e7a' },
                  disabled && styles.boxDisabled,
                ]}>
                {({ pressed }) => (
                  <Text style={styles.boxText}>
                    {pressed ? 'PRESSED' : engine.short}
                  </Text>
                )}
              </Component>
            </View>
          );
        })}
      </View>

      <View style={styles.logHeader}>
        <Text style={styles.logTitle}>Event log (newest first)</Text>
        <Text style={styles.clear} onPress={clearLog}>
          clear
        </Text>
      </View>
      <View style={styles.logBox}>
        {log.length === 0 ? (
          <Text style={styles.logEmpty}>No events yet</Text>
        ) : (
          log.map((line) => (
            <Text
              key={line}
              style={[
                styles.logLine,
                line.includes('STATEFUL')
                  ? styles.logStateful
                  : styles.logTouchable,
              ]}>
              {line}
            </Text>
          ))
        )}
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
    paddingTop: 40,
  },
  hint: {
    fontSize: 13,
    color: '#607d8b',
    marginBottom: 12,
  },
  controls: {
    marginBottom: 16,
  },
  toggleRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    paddingVertical: 4,
  },
  toggleLabel: {
    fontSize: 15,
    color: '#37474f',
    fontFamily: 'monospace',
  },
  columns: {
    flexDirection: 'row',
    gap: 12,
  },
  column: {
    flex: 1,
    alignItems: 'center',
  },
  columnTitle: {
    fontSize: 13,
    fontWeight: '600',
    textAlign: 'center',
    marginBottom: 8,
    color: '#455a64',
  },
  box: {
    width: '100%',
    height: 90,
    borderRadius: 10,
    alignItems: 'center',
    justifyContent: 'center',
  },
  boxDisabled: {
    opacity: 0.4,
  },
  boxText: {
    color: 'white',
    fontWeight: '700',
    letterSpacing: 1,
  },
  logHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginTop: 20,
    marginBottom: 6,
  },
  logTitle: {
    fontSize: 14,
    fontWeight: '600',
    color: '#37474f',
  },
  clear: {
    fontSize: 14,
    color: '#1976d2',
    padding: 4,
  },
  logBox: {
    minHeight: 120,
    backgroundColor: '#eceff1',
    borderRadius: 8,
    padding: 10,
  },
  logEmpty: {
    color: '#90a4ae',
    fontStyle: 'italic',
  },
  logLine: {
    fontFamily: 'monospace',
    fontSize: 13,
    paddingVertical: 1,
  },
  logStateful: {
    color: '#6a1b9a',
  },
  logTouchable: {
    color: '#00695c',
  },
});

Copilot AI lite review requested due to automatic review settings August 11, 2026 08:47
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Improved Pressable interactions for touch, long-press, hover, keyboard, accessibility, TV focus, and Android ripple effects.
    • Added support for coordinating presses with related gestures, including simultaneous gestures, failure requirements, and blocking.
    • Enhanced hit slop, delayed activation, cancellation, layout tracking, and development debugging.
  • Bug Fixes

    • Improved Pressable responder behavior inside scrollable views for more reliable interactions.
    • Improved handling when gesture-related properties change during rendering.

Walkthrough

Pressable now selects a Touchable-backed implementation unless gesture-relation props require the stateful gesture implementation. Changing relation props can remount the active implementation. Responder tests now inspect Pressable callbacks directly.

Changes

Pressable interaction handling

Layer / File(s) Summary
Pressable implementation dispatch
packages/react-native-gesture-handler/src/v3/components/Pressable.tsx
Pressable selects StatefulPressable when relation props are non-null. Otherwise, it selects PressableWithTouchable.
Touchable-backed press and hover flow
packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx, packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx
PressableWithTouchable maps events, manages delays and cleanup, normalizes configuration, renders Touchable, and maps callbacks. Responder tests inspect Pressable responder behavior directly.
Stateful gesture press flow
packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx
StatefulPressable manages press state, long-press and hover timing, cancellation, keyboard dismissal, gesture coordination, and platform behavior.
Stateful rendering and platform integration
packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx
The component renders coordinated gestures with native button handling, layout updates, accessibility, styling, ripple configuration, TV and web behavior, test callbacks, and debug rendering.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Pressable
  participant PressableWithTouchable
  participant Touchable
  Caller->>Pressable: provide Pressable props
  Pressable->>PressableWithTouchable: select default implementation
  PressableWithTouchable->>Touchable: configure mapped props and callbacks
  Touchable-->>PressableWithTouchable: emit press or hover event
  PressableWithTouchable-->>Caller: dispatch Pressable callback
Loading

Possibly related PRs

Suggested reviewers: j-piasecki

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: implementing Pressable with Touchable as its default implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx (1)

55-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the component to StatefulPressable.

The component is named Pressable but the file is StatefulPressable.tsx, and Pressable.tsx exports a different component with the same name. React DevTools, error boundaries, and stack traces then show two distinct components as Pressable.

♻️ Proposed rename
-const Pressable = (props: PressableProps) => {
+const StatefulPressable = (props: PressableProps) => {

Update the export at line 453:

-export default Pressable;
+export default StatefulPressable;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx`
at line 55, Rename the component declaration currently named Pressable to
StatefulPressable and update its corresponding export so the module consistently
exposes that name. Preserve all existing props, behavior, and references within
the component.
packages/react-native-gesture-handler/src/v3/components/Pressable.tsx (1)

20-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a __DEV__ warning when relation props appear after mount.

The engine choice is latched at mount. If a consumer adds simultaneousWith, requireToFail, or block later, PressableWithTouchable silently drops them (see PressableWithTouchable.tsx lines 84-91). The press keeps working, so the failure is invisible and hard to debug.

A development-only warning makes the constraint explicit without changing runtime behavior.

♻️ Proposed dev-only warning
 const Pressable = (props: PressableProps) => {
   const usesRelations = useRef(
     props.simultaneousWith != null ||
       props.requireToFail != null ||
       props.block != null
   ).current;
 
+  if (__DEV__ && !usesRelations) {
+    if (
+      props.simultaneousWith != null ||
+      props.requireToFail != null ||
+      props.block != null
+    ) {
+      console.warn(
+        '[RNGH] Pressable: relation props added after mount are ignored. Pass them on the first render.'
+      );
+    }
+  }
+
   return usesRelations ? (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-gesture-handler/src/v3/components/Pressable.tsx` around
lines 20 - 31, In the Pressable component, add a __DEV__-only warning when
relation props (simultaneousWith, requireToFail, or block) become defined after
the initial mount while usesRelations remains false. Preserve the existing
latched engine selection and runtime behavior, and warn that these props are
ignored by PressableWithTouchable.
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx (1)

260-266: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both responder assertions check a constant return value. Touchable.onStartShouldSetResponderCapture always returns false, so both tests pass regardless of keyboardShouldPersistTaps mode and cannot detect a regression in the responder-claim side effect.

  • packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L260-L266: replace the return-value check with an assertion that the Pressable claims the responder event for RNGH in handled mode.
  • packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L285-L290: assert that no responder claim reaches the ScrollView outside handled mode, instead of re-checking the same false return value.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx` around
lines 260 - 266, Update the responder assertions in
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx at lines
260-266 and 285-290: in the handled-mode test, assert that the Pressable claims
the responder event for RNGH rather than checking
Touchable.onStartShouldSetResponderCapture’s constant false return; outside
handled mode, assert that no responder claim reaches the ScrollView. Use the
existing responder-claim tracking or mock symbols in the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx`:
- Around line 139-148: Update handlePressIn to clear any existing
timers.current.press before scheduling a new press-delay timeout, while
preserving the immediate firePressIn path when no delay is configured.
- Line 217: Update the delayLongPress prop in PressableWithTouchable so an
undefined value defaults to 500 ms before being passed to Touchable, matching
StatefulPressable’s default while preserving explicitly provided delays.

In
`@packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx`:
- Around line 86-99: Mirror the timer cleanup used in PressableWithTouchable.tsx
by adding an unmount cleanup effect in StatefulPressable that clears
longPressTimeoutRef, pressDelayTimeoutRef, hoverInTimeout, and hoverOutTimeout.
Ensure pending callbacks cannot invoke onLongPress, onPressIn, onHoverIn, or
onHoverOut after unmount.

---

Nitpick comments:
In `@packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx`:
- Around line 260-266: Update the responder assertions in
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx at lines
260-266 and 285-290: in the handled-mode test, assert that the Pressable claims
the responder event for RNGH rather than checking
Touchable.onStartShouldSetResponderCapture’s constant false return; outside
handled mode, assert that no responder claim reaches the ScrollView. Use the
existing responder-claim tracking or mock symbols in the test.

In `@packages/react-native-gesture-handler/src/v3/components/Pressable.tsx`:
- Around line 20-31: In the Pressable component, add a __DEV__-only warning when
relation props (simultaneousWith, requireToFail, or block) become defined after
the initial mount while usesRelations remains false. Preserve the existing
latched engine selection and runtime behavior, and warn that these props are
ignored by PressableWithTouchable.

In
`@packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx`:
- Line 55: Rename the component declaration currently named Pressable to
StatefulPressable and update its corresponding export so the module consistently
exposes that name. Preserve all existing props, behavior, and references within
the component.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9731866-b7a1-40e4-8365-a9bb7fbd9c8f

📥 Commits

Reviewing files that changed from the base of the PR and between a3377f0 and 78a9070.

📒 Files selected for processing (4)
  • packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx
  • packages/react-native-gesture-handler/src/v3/components/Pressable.tsx
  • packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx
  • packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx

Comment on lines +86 to +99
const longPressTimeoutRef = useRef<number | null>(null);
const pressDelayTimeoutRef = useRef<number | null>(null);
const isOnPressAllowed = useRef<boolean>(true);
const jsResponderContext = use(JSResponderContext);
const isCurrentlyPressed = useRef<boolean>(false);
const dimensions = useRef<PressableDimensions>({
width: 0,
height: 0,
});

// When the touch that begins a press is the one dismissing the keyboard
// (keyboardShouldPersistTaps="never"), the press is swallowed to match RN's
// touchables.
const dropKeyboardTapRef = useRef<boolean | null>(null);

@coderabbitai coderabbitai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Pending timers are never cleared on unmount.

longPressTimeoutRef, pressDelayTimeoutRef, hoverInTimeout, and hoverOutTimeout hold scheduled callbacks. The file contains no cleanup effect. If the component unmounts while a timer is pending, onLongPress, onPressIn, onHoverIn, or onHoverOut still fires and calls into a torn-down component.

PressableWithTouchable.tsx lines 99-116 add exactly this cleanup. Mirror it here so both engines behave the same.

🐛 Proposed cleanup effect
   const hoverInTimeout = useRef<number | null>(null);
   const hoverOutTimeout = useRef<number | null>(null);
+
+  useEffect(
+    () => () => {
+      for (const timer of [
+        longPressTimeoutRef,
+        pressDelayTimeoutRef,
+        hoverInTimeout,
+        hoverOutTimeout,
+      ]) {
+        if (timer.current) {
+          clearTimeout(timer.current);
+          timer.current = null;
+        }
+      }
+    },
+    []
+  );

Also applies to: 234-235

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx`
around lines 86 - 99, Mirror the timer cleanup used in
PressableWithTouchable.tsx by adding an unmount cleanup effect in
StatefulPressable that clears longPressTimeoutRef, pressDelayTimeoutRef,
hoverInTimeout, and hoverOutTimeout. Ensure pending callbacks cannot invoke
onLongPress, onPressIn, onHoverIn, or onHoverOut after unmount.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This will be fixed in a follow-up

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@m-bert, acknowledged. The timer cleanup remains pending for the follow-up.

Would you like me to open a GitHub issue for it?

You are interacting with an AI system.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 refactors the v3 Pressable implementation by splitting it into two internal engines while keeping the public API unchanged: a default engine built on the native-button Touchable, and a state-machine-based engine used when gesture relation props are provided.

Changes:

  • Added PressableWithTouchable (default engine) and extracted the prior state-machine logic into StatefulPressable.
  • Updated Pressable to choose the engine once at mount time based on presence of relation props (simultaneousWith / requireToFail / block).
  • Updated v3 API tests to assert responder handling against the rendered pressable button rather than the prior NativeDetector path.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx New extracted state-machine-based Pressable engine (used when relation props are present).
packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx New Touchable-based Pressable engine intended as the default path.
packages/react-native-gesture-handler/src/v3/components/Pressable.tsx Wrapper now routes to the appropriate internal engine once per mount.
packages/react-native-gesture-handler/src/tests/api_v3.test.tsx Adjusts ScrollView responder tests to target the pressable button node.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx Outdated
@m-bert

m-bert commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review again pls 👀

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

@m-bert: I will perform a new full review of the current changes.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx (1)

61-99: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider dropping the relation props in one destructuring step.

rest is destructured a second time only to remove cancelable, dimensionsAfterResize, simultaneousWith, requireToFail, and block. You can list these keys in the first destructuring and delete the eslint-disable block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx`
around lines 61 - 99, Update the initial props destructuring in
PressableWithTouchable to omit cancelable, dimensionsAfterResize,
simultaneousWith, requireToFail, and block alongside the other excluded props,
then use the resulting rest object directly and remove the redundant second
destructuring and eslint suppression.
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx (1)

260-266: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The new responder assertions cannot fail in either keyboard mode. onStartShouldSetResponderCapture in Touchable returns false unconditionally; its real effect is the updateResponderEventValue(jsResponderContext, true) call, which neither test observes. Both new assertions therefore pass regardless of the mode under test.

  • packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L260-L266: replace the constant-return assertion with an observable outcome, for example that the keyboard stays open and the Pressable callbacks run.
  • packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L285-L290: remove the redundant return-value assertion, or assert that the press does dismiss the keyboard in this mode.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx` around
lines 260 - 266, The responder assertions do not verify mode-specific behavior
because onStartShouldSetResponderCapture always returns false. In
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L260-L266,
replace the return-value checks with observable outcomes such as the keyboard
remaining open and Pressable callbacks running; in
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L285-L290,
remove the redundant assertion or verify that pressing dismisses the keyboard in
that mode.
packages/react-native-gesture-handler/src/v3/components/Pressable.tsx (1)

20-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Warn in development when relation props appear after mount.

The engine choice is latched at mount. If a consumer renders Pressable without relation props and later passes simultaneousWith, requireToFail, or block, PressableWithTouchable destructures those props and discards them. The gesture relation is then silently inactive for the whole lifetime of the component. A __DEV__ warning makes this contract visible.

♻️ Proposed dev-only warning
 const Pressable = (props: PressableProps) => {
+  const hasRelations =
+    props.simultaneousWith != null ||
+    props.requireToFail != null ||
+    props.block != null;
+
   const usesRelations = useRef(
-    props.simultaneousWith != null ||
-      props.requireToFail != null ||
-      props.block != null
+    hasRelations
   ).current;
 
+  if (__DEV__ && hasRelations !== usesRelations) {
+    console.warn(
+      '[RNGH] Pressable: `simultaneousWith`/`requireToFail`/`block` were added or removed after mount. The implementation is selected once at mount, so the change has no effect.'
+    );
+  }
+
   return usesRelations ? (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-gesture-handler/src/v3/components/Pressable.tsx` around
lines 20 - 31, Add a development-only warning in the Pressable component when
relation props are introduced after mount, using a ref to compare the current
presence of simultaneousWith, requireToFail, and block against the initial
usesRelations value. Keep the existing latched renderer selection unchanged and
warn only when the initial selection was PressableWithTouchable but a relation
prop later becomes present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx`:
- Around line 199-208: Align Touchable long-press timing with StatefulPressable
by including unstable_pressDelay in the delayLongPress value and flushing the
pending press timer before invoking the long-press handler. Update the relevant
press-timer and long-press handling around makeActiveHandler so onLongPress
cannot fire before the deferred onPressIn.

In
`@packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx`:
- Line 84: Update StatefulPressable’s pressed-state handling so style and
function-valued children derive their displayed state from the current
testOnly_pressed prop when provided, while retaining pressedState otherwise;
apply testOnly_pressed ?? pressedState at both callback invocation sites and add
rerender coverage for prop changes.

---

Nitpick comments:
In `@packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx`:
- Around line 260-266: The responder assertions do not verify mode-specific
behavior because onStartShouldSetResponderCapture always returns false. In
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L260-L266,
replace the return-value checks with observable outcomes such as the keyboard
remaining open and Pressable callbacks running; in
packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx#L285-L290,
remove the redundant assertion or verify that pressing dismisses the keyboard in
that mode.

In `@packages/react-native-gesture-handler/src/v3/components/Pressable.tsx`:
- Around line 20-31: Add a development-only warning in the Pressable component
when relation props are introduced after mount, using a ref to compare the
current presence of simultaneousWith, requireToFail, and block against the
initial usesRelations value. Keep the existing latched renderer selection
unchanged and warn only when the initial selection was PressableWithTouchable
but a relation prop later becomes present.

In
`@packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx`:
- Around line 61-99: Update the initial props destructuring in
PressableWithTouchable to omit cancelable, dimensionsAfterResize,
simultaneousWith, requireToFail, and block alongside the other excluded props,
then use the resulting rest object directly and remove the redundant second
destructuring and eslint suppression.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 62bf5133-b2d8-43e9-86dd-c90524816683

📥 Commits

Reviewing files that changed from the base of the PR and between a3377f0 and 05639e3.

📒 Files selected for processing (4)
  • packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx
  • packages/react-native-gesture-handler/src/v3/components/Pressable.tsx
  • packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx
  • packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx

...remainingProps
} = props;

const [pressedState, setPressedState] = useState(testOnly_pressed ?? false);

@coderabbitai coderabbitai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
file='packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx'
ast-grep outline "$file" --lang tsx
printf '\n--- relevant source ---\n'
sed -n '1,130p' "$file"
sed -n '380,425p' "$file"
printf '\n--- prop and state usages ---\n'
rg -n -C 3 'testOnly_pressed|pressedState|style|children' "$file"
printf '\n--- related tests ---\n'
rg -n -C 3 'StatefulPressable|testOnly_pressed' packages/react-native-gesture-handler --glob '*.{ts,tsx,js,jsx}'

Repository: software-mansion/react-native-gesture-handler

Length of output: 14287


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- StatefulPressable state transitions and cleanup ---'
sed -n '80,245p' packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx
sed -n '245,405p' packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx
printf '%s\n' '--- PressableProps contract ---'
sed -n '160,195p' packages/react-native-gesture-handler/src/components/Pressable/PressableProps.tsx
printf '%s\n' '--- comparable implementations ---'
sed -n '75,125p' packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx
sed -n '70,115p' packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx
printf '%s\n' '--- testOnly_pressed references ---'
rg -n -C 5 'testOnly_pressed' . --glob '!node_modules/**' --glob '!build/**' --glob '!dist/**'

Repository: software-mansion/react-native-gesture-handler

Length of output: 25090


🏁 Script executed:

#!/bin/bash
set -e
node - <<'JS'
function mount(testOnly_pressed) {
  let pressedState = testOnly_pressed ?? false;
  return {
    render(nextTestOnlyPressed) {
      // Current implementation: useState initializer is ignored after mount.
      const currentImplementation = pressedState;
      // Proposed implementation: testOnly_pressed controls only the displayed value.
      const proposedImplementation = nextTestOnlyPressed ?? pressedState;
      return { currentImplementation, proposedImplementation };
    },
    pressIn() {
      pressedState = true;
    },
    pressOut() {
      pressedState = false;
    },
  };
}

const cases = [
  ['false -> true', false, true],
  ['true -> false', true, false],
  ['true -> removed', true, undefined],
  ['false -> removed', false, undefined],
];

for (const [name, initial, next] of cases) {
  const state = mount(initial);
  console.log(name, state.render(initial));
  console.log(' rerender', state.render(next));
}

const interaction = mount(undefined);
interaction.pressIn();
console.log('interaction press-in with override removed', interaction.render(undefined));
interaction.pressOut();
console.log('interaction press-out with override removed', interaction.render(undefined));
JS

printf '\n--- local Pressable documentation and implementation references ---\n'
rg -n -C 8 'testOnly_pressed|currently pressed|style.*function|children.*function' \
  packages/react-native-gesture-handler/src \
  packages/docs-gesture-handler/docs \
  packages/docs-gesture-handler/versioned_docs

Repository: software-mansion/react-native-gesture-handler

Length of output: 40709


🌐 Web query:

React Native Pressable source testOnly_pressed useState style pressed children prop updates

💡 Result:

The testOnly_pressed prop in React Native's Pressable component is a specialized property intended exclusively for documentation, snapshot testing, or automated testing scenarios [1][2]. It allows developers to force the component into its pressed state programmatically, bypassing the need for manual press events [3]. When you set testOnly_pressed={true} on a Pressable component, it overrides the internal state and effectively behaves as if the component is being pressed [3]. This triggers the Pressable component to execute its style and children functions with the pressed property set to true [3][4]. For example, if your Pressable uses a function for its style or children prop, passing testOnly_pressed will force those functions to receive { pressed: true } as an argument [3][4]: <Pressable testOnly_pressed={true} style={({ pressed }) => ({ backgroundColor: pressed? 'red': 'blue' })} > {({ pressed }) => {pressed? 'Active': 'Idle'}} In this example, both the style and the children render function would reflect the 'pressed' state, rendering the background red and the text as 'Active' [4]. This is particularly useful for verifying the UI appearance of the pressed state in testing environments without requiring simulated user interactions like firing press events [3]. Note that this prop should be used only for testing and debugging purposes [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
import urllib.request

urls = [
    "https://raw.githubusercontent.com/facebook/react-native/main/packages/react-native/Libraries/Components/Pressable/Pressable.js",
    "https://raw.githubusercontent.com/facebook/react-native/v0.80.2/packages/react-native/Libraries/Components/Pressable/Pressable.js",
]
for url in urls:
    print(f"--- {url} ---")
    try:
        text = urllib.request.urlopen(url, timeout=10).read().decode()
    except Exception as exc:
        print(f"ERROR: {exc}")
        continue
    lines = text.splitlines()
    for i, line in enumerate(lines):
        if "testOnly_pressed" in line or "pressed:" in line:
            start = max(0, i - 8)
            end = min(len(lines), i + 12)
            print("\n".join(f"{j+1}: {lines[j]}" for j in range(start, end)))
            print()
PY

Repository: software-mansion/react-native-gesture-handler

Length of output: 740


Derive the displayed pressed state from testOnly_pressed.

useState(testOnly_pressed ?? false) reads testOnly_pressed only on mount. Prop changes can leave style and function-valued children with a stale pressed state. Use testOnly_pressed ?? pressedState when invoking both callbacks. Add rerender coverage for prop changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx`
at line 84, Update StatefulPressable’s pressed-state handling so style and
function-valued children derive their displayed state from the current
testOnly_pressed prop when provided, while retaining pressedState otherwise;
apply testOnly_pressed ?? pressedState at both callback invocation sites and add
rerender coverage for prop changes.

Source: Coding guidelines

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Changes to old Pressable will be considered in a follow-up

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@m-bert, understood. This finding affects the extracted state-machine-based Pressable and is out of scope for this PR. I will leave it for the follow-up.

Would you like me to create a GitHub issue for the testOnly_pressed state update and rerender coverage?

You are interacting with an AI system.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (4)

packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx:183

  • The deferred onPressIn timer doesn't guard against disabled flipping before it fires. Even with timer cleanup on unmount, a disabled toggle can still result in onPressIn firing later unless the callback checks current disabled state (or is cleared synchronously on disable).
      timers.current.press = setTimeout(() => {
        timers.current.press = null;
        firePressIn(event);
      }, unstable_pressDelay);

packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx:453

  • After renaming the component to StatefulPressable, the default export should be updated to export the renamed symbol (otherwise the file will no longer compile).
export default Pressable;

packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx:124

  • unstable_pressDelay/hover timers can still fire after the component becomes disabled (e.g. disabled toggles to true while a delayed onPressIn is pending), causing callbacks/state updates on a now-disabled Pressable. Consider tracking the latest disabled state and clearing pending timers + resetting pressed state when disabled becomes true.

This issue also appears on line 180 of the same file.

  const [pressed, setPressed] = useState(testOnly_pressed ?? false);
  const timers = useRef<Timers>({ press: null, hoverIn: null, hoverOut: null });
  const dimensions = useRef<PressableDimensions>({ width: 0, height: 0 });

  // Whether the in-progress press activated within hitSlop (see handlePressIn).
  const isActive = useRef(false);

  useEffect(
    () => () => {
      const pending = timers.current;

      if (pending.press) {
        clearTimeout(pending.press);
      }

      if (pending.hoverIn) {
        clearTimeout(pending.hoverIn);
      }

      if (pending.hoverOut) {
        clearTimeout(pending.hoverOut);
      }
    },
    []
  );

packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx:55

  • The component is declared as Pressable in StatefulPressable.tsx, which makes stack traces/devtools ambiguous next to the public Pressable wrapper. Renaming the component to match the file improves debugging and avoids confusion.

This issue also appears on line 453 of the same file.

const Pressable = (props: PressableProps) => {

Comment on lines +21 to +25
const usesRelations = useRef(
props.simultaneousWith != null ||
props.requireToFail != null ||
props.block != null
).current;

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.

Reading refs in render is an antipattern. I don't think it bites here since the value doesn't change, but still. I think we can allow swapping the two at runtime, with its consequences.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

3 participants