Skip to content

Remove unused enabled listener from useJSResponderHandler - #4663

Merged
m-bert merged 2 commits into
@mbert/virtual-detector-js-responderfrom
@mbert/js-responder-drop-enabled-listener
Nov 4, 2026
Merged

m-bert merged 2 commits into
@mbert/virtual-detector-js-responderfrom
@mbert/js-responder-drop-enabled-listener

Conversation

@m-bert

@m-bert m-bert commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Description

#4158 added a SharedValue listener to useJSResponderHandler: whenever a SharedValue passed as enabled changed, it bumped a state counter so that the responder handler was re-created. The handler reads enabled when the touch starts (maybeUnpackValue returns .value), and it already did so when the listener was added, so the counter never changed the result. It only cost a state, a ref and an effect with UI-thread scheduling for every detector, plus a re-render of the detector on every flip.

This PR removes the listener and the now unused getEnabledSharedValues. The hook is now a context read and a single callback, and without a ScrollView above it returns a stable no-op instead of a new function on every render. This also applies to NativeDetector, which has run the listener since #4158.

Test plan

  • New Jest test that flips a SharedValue enabled without a re-render and checks that the marking follows it. It fails if enabled is evaluated during render.
  • Android emulator and iOS simulator, identical results before and after:
    • the Fix handling keyboard should persists taps #4158 example (Keyboard Should Persist Taps), all three modes with GH Pressable, Touchable and useTapGesture: press with the keyboard open, then a tap on the blank area,
    • the code below, with enabled flipped from JS and from the UI thread: disabled closes the keyboard and the tap doesn't fire, enabled keeps the keyboard open and the tap fires.
Tested on the following code:
import React, { memo, useCallback, useEffect, useState } from 'react';
import {
  Keyboard,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import {
  GestureDetector,
  ScrollView,
  useTapGesture,
  VirtualGestureDetector,
} from 'react-native-gesture-handler';
import { type SharedValue, useSharedValue } from 'react-native-reanimated';
import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';

// Rows are memoized and never re-render. Flip `enabled` from JS or the UI
// thread, focus the input, tap a row. Disabled: keyboard closes, tap doesn't
// fire. Enabled: keyboard stays open, tap fires.

type RowProps = {
  label: string;
  virtual: boolean;
  enabled: SharedValue<boolean>;
  onTap: () => void;
};

const Row = memo(function Row({ label, virtual, enabled, onTap }: RowProps) {
  const tap = useTapGesture({ enabled, runOnJS: true, onDeactivate: onTap });
  const Detector = virtual ? VirtualGestureDetector : GestureDetector;

  return (
    <Detector gesture={tap}>
      <View style={styles.row}>
        <Text>{label}</Text>
      </View>
    </Detector>
  );
});

export default function EmptyExample() {
  const [keyboardVisible, setKeyboardVisible] = useState(false);
  const [taps, setTaps] = useState({ native: 0, virtual: 0 });
  const [mirror, setMirror] = useState({ native: true, virtual: true });

  const nativeEnabled = useSharedValue(true);
  const virtualEnabled = useSharedValue(true);

  useEffect(() => {
    const show = Keyboard.addListener('keyboardDidShow', () =>
      setKeyboardVisible(true)
    );
    const hide = Keyboard.addListener('keyboardDidHide', () =>
      setKeyboardVisible(false)
    );
    return () => {
      show.remove();
      hide.remove();
    };
  }, []);

  const onNativeTap = useCallback(
    () => setTaps((t) => ({ ...t, native: t.native + 1 })),
    []
  );
  const onVirtualTap = useCallback(
    () => setTaps((t) => ({ ...t, virtual: t.virtual + 1 })),
    []
  );

  const reportMirror = (key: 'native' | 'virtual', value: boolean) =>
    setMirror((m) => ({ ...m, [key]: value }));

  const setFromJS = (key: 'native' | 'virtual', value: boolean) => {
    (key === 'native' ? nativeEnabled : virtualEnabled).value = value;
    reportMirror(key, value);
  };

  const setFromUI = (key: 'native' | 'virtual', value: boolean) => {
    const sv = key === 'native' ? nativeEnabled : virtualEnabled;
    scheduleOnUI(() => {
      'worklet';
      sv.value = value;
      scheduleOnRN(reportMirror, key, value);
    });
  };

  const controls: [string, () => void][] = [
    ['JS N off', () => setFromJS('native', false)],
    ['JS N on', () => setFromJS('native', true)],
    ['UI N off', () => setFromUI('native', false)],
    ['UI N on', () => setFromUI('native', true)],
    ['JS V off', () => setFromJS('virtual', false)],
    ['JS V on', () => setFromJS('virtual', true)],
    ['UI V off', () => setFromUI('virtual', false)],
    ['UI V on', () => setFromUI('virtual', true)],
  ];

  return (
    <View style={styles.container}>
      <Text style={styles.status} testID="status">
        keyboard: {keyboardVisible ? 'OPEN' : 'closed'} | N
        {mirror.native ? 'on' : 'off'} taps {taps.native} | V
        {mirror.virtual ? 'on' : 'off'} taps {taps.virtual}
      </Text>
      <View style={styles.controls}>
        {controls.map(([label, action]) => (
          <Pressable
            key={label}
            style={styles.control}
            onPress={action}
            testID={label}>
            <Text style={styles.controlText}>{label}</Text>
          </Pressable>
        ))}
      </View>
      <ScrollView keyboardShouldPersistTaps="handled">
        <TextInput style={styles.input} placeholder="focus me" testID="input" />
        <Row
          label="N: GestureDetector Tap"
          virtual={false}
          enabled={nativeEnabled}
          onTap={onNativeTap}
        />
        <Row
          label="V: VirtualGestureDetector Tap"
          virtual
          enabled={virtualEnabled}
          onTap={onVirtualTap}
        />
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  status: { fontSize: 14, padding: 6, textAlign: 'center' },
  controls: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 6,
    paddingHorizontal: 8,
  },
  control: {
    paddingVertical: 6,
    paddingHorizontal: 8,
    borderRadius: 6,
    backgroundColor: '#ccd',
  },
  controlText: { fontSize: 12 },
  input: {
    marginHorizontal: 16,
    marginTop: 8,
    padding: 8,
    borderWidth: 1,
    borderColor: '#888',
    borderRadius: 8,
  },
  row: {
    height: 44,
    marginHorizontal: 16,
    marginTop: 8,
    backgroundColor: '#cde',
    borderRadius: 8,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

@m-bert
m-bert added this pull request to stack #4664 November 3, 2026 10:20
Copilot AI balanced review requested due to automatic review settings November 3, 2026 10:20
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Summary

Summary by CodeRabbit

  • Bug Fixes
    • Gesture responder handling now reflects changes to a gesture’s enabled state when a touch starts, even if the hook hasn’t re-rendered.

The responder handler now checks gesture support when a touch starts. The change removes shared-value listener tracking and adds a test for enabled-value changes without a hook re-render.

Changes: Responder handling

Layer / File(s) Summary
Responder-start checks
packages/react-native-gesture-handler/src/v3/hooks/utils/reanimatedUtils.ts, packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts, packages/react-native-gesture-handler/src/__tests__/jsResponderHandler.test.tsx
The handler checks gesture support at touch-start time and uses a shared no-op handler when the context is absent. The shared-value listener helper is removed. The test checks the responder result as the enabled value changes without a hook re-render.

Possibly related PRs

Suggested reviewers: coado, j-piasecki

Merge Risk: Minimal · up to 7f406

Responder handling retains enabled-state updates, with no actionable merge-blocking risk identified.

Pre-merge checks: Passed 3
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: removing the unused enabled listener from useJSResponderHandler.
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.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6f6280dd-4b73-4c29-a5e0-a3ae89f17278
Commits

Reviewing files that changed from the base of the PR and between bdd3b6f and 7f40686.

Files selected for processing: 1
  • packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

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.

🟡 Changes recommended

Hard-coding native-gesture handling to false in the public InterceptingGestureDetector can cause enclosing ScrollViews to dismiss the keyboard after handled native-control taps; the opt-out should be limited to the ScrollView wrapper.

1 open issue
What changed in this PR

This PR simplifies v3 gesture responder handling by removing SharedValue listeners and relying on touch-time enabled reads, reducing detector state and re-renders.

Changes:

  • Removes listener bookkeeping and the unused getEnabledSharedValues helper; uses a stable no-op outside responder context.
  • Consolidates the intercepting detector and changes its native gestures to never mark taps as handled.
  • Adds coverage for SharedValue changes without re-rendering and updates intercepting-detector expectations.
File Description
packages/​react-native-gesture-handler/​src/​v3/​hooks/​utils/​reanimatedUtils.ts Removes the unused SharedValue collection helper.
packages/​react-native-gesture-handler/​src/​v3/​hooks/​useJSResponderHandler.ts Removes listeners and simplifies the responder callback.
packages/​react-native-gesture-handler/​src/​v3/​detectors/​VirtualDetector/​InterceptingGestureDetector.tsx Consolidates the detector and disables native-gesture marking.
packages/​react-native-gesture-handler/​src/​v3/​createNativeWrapper.tsx Adjusts detector selection and responder-option forwarding.
packages/​react-native-gesture-handler/​src/​v3/​components/​GestureComponents.web.tsx Removes the explicit ScrollView opt-out argument.
packages/​react-native-gesture-handler/​src/​v3/​components/​GestureComponents.tsx Removes the explicit ScrollView opt-out argument.
packages/​react-native-gesture-handler/​src/​__tests__/​jsResponderHandler.test.tsx Tests touch-time reads of changing SharedValue state.
packages/​react-native-gesture-handler/​src/​__tests__/​api_v3.test.tsx Updates native-gesture responder expectations.

Review effort: Balanced

const { handleStartShouldSetResponder } = useJSResponderHandler(
gesture,
countNativeAsHandled
false

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.

Out of scope for this PR (the line comes from #4530), and intentional there. On main InterceptingGestureDetector never marked taps, so this isn't a regression. We tried counting its Native gesture by default and it kept the keyboard open on every tap on pagers wrapped in an InterceptingGestureDetector (verified on Android and iOS), with no way for users to opt out. Its Native gesture belongs to the container hosting virtual children; native tap targets should use GestureDetector, which still marks them.

@m-bert
m-bert merged commit c3c8c15 into main Nov 4, 2026
11 checks passed
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