Skip to content

ScrollView/FlatList silently disable pull-to-refresh when given React Native's RefreshControl #4485

Description

@khemraj-astro

Description

What happens: ScrollView / FlatList from gesture-handler silently disable pull-to-refresh when they are given React Native's RefreshControl instead of the one gesture-handler exports. There is no warning and no error. The spinner drags normally, but on release the gesture is cancelled instead of released: onRefresh never fires, and the spinner stays parked at the dragged offset until the next touch anywhere on the screen snaps it back.

What I expected: either pull-to-refresh works with RN's RefreshControl, or the mismatch is surfaced — a __DEV__ warning, or a documented requirement on the ScrollView page.

This is easy to hit because RN's RefreshControl is the one every RN codebase already imports, and GHScrollView accepts it without complaint. It is not a duplicate of #1067 (closed): pull-to-refresh does work, provided you happen to know you must also swap the RefreshControl import.

Steps to reproduce

  1. Render a gesture-handler ScrollView with a refreshControl element imported from react-native (code below).
  2. Run on Android.
  3. Pull down from the top of the list and release.
  4. onRefresh does not fire — nothing refreshes, and the spinner stays frozen part-way down instead of retracting.
  5. Tap anywhere on the screen — the spinner snaps back up.
  6. Change only the import to import { RefreshControl } from 'react-native-gesture-handler' and repeat: works correctly every time.
import React, { useState, useCallback } from 'react';
import { RefreshControl, View, Text } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';

export default function App() {
  const [refreshing, setRefreshing] = useState(false);
  const onRefresh = useCallback(() => {
    console.log('onRefresh fired');          // never logs
    setRefreshing(true);
    setTimeout(() => setRefreshing(false), 1000);
  }, []);

  return (
    <ScrollView
      style={{ flex: 1 }}
      refreshControl={
        // Importing RefreshControl from 'react-native-gesture-handler'
        // instead makes the bug disappear.
        <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
      }>
      {Array.from({ length: 40 }, (_, i) => (
        <View key={i} style={{ padding: 20 }}>
          <Text>Row {i}</Text>
        </View>
      ))}
    </ScrollView>
  );
}

Platforms

Android only. iOS is unaffected — RN's ScrollView is used there in our setup, so no gesture-handler wrapper is involved.

Versions

Package Version installed
react-native-gesture-handler 2.32.0
react-native 0.86.2 (New Architecture / Fabric, Hermes)
react-native-reanimated 4.5.3
react-native-worklets 0.10.4
react-native-screens 4.26.2
expo 57.0.12

Tested on an Android emulator: Pixel 6a, API 36 (Android 16), arm64-v8a, 1080×2340 @ 420dpi.

Root cause

ScrollView in src/components/GestureComponents.tsx clones the refreshControl element to attach its own ref, and makes the scroll handler waitFor it:

const refreshControlGestureRef = React.useRef<RefreshControl>(null);
...
waitFor={[...toArray(waitFor ?? []), refreshControlGestureRef]}
refreshControl={React.cloneElement(refreshControl, { ref: refreshControlGestureRef })}

That only yields a gesture handler when refreshControl is gesture-handler's own RefreshControl (createNativeWrapper(RNRefreshControl, …)), whose useImperativeHandle exposes handlerTag. RN's RefreshControl is a plain class component: the ref resolves to the class instance, it has no handlerTag, and the waitFor entry is silently dropped.

The single NativeViewGestureHandler then attaches to the ReactSwipeRefreshLayout (RN's Android ScrollView renders the SwipeRefreshLayout as its root when refreshControl is set), so onPrepare() selects SwipeRefreshLayoutHook — the hook written for the refresh control's handler, not the scroll handler. Its handleEventBeforeActivation looks for a NativeViewGestureHandler on the inner ScrollView, finds none (there is only one handler), and returns.

The pull therefore ends in a cancel rather than a release, and androidx's SwipeRefreshLayout ignores ACTION_CANCEL — it never calls finishSpinner(), so mRefreshing stays false and the circle is never animated back. The next ACTION_DOWN resets it via onInterceptTouchEvent, which is why a tap appears to "fix" it.

Measured

Injected drags, identical gesture each trial:

Setup onRefresh fired spinner parked
GH ScrollView + RN RefreshControl 0/9 4/9
RN ScrollView + RN RefreshControl 5/5 0/5
GH ScrollView + GH RefreshControl 8/8 0/8
GH FlatList + RN RefreshControl 0/5 5/5
GH FlatList + GH RefreshControl 6/6 0/6

The parked-spinner outcome is timing-dependent (a longer dwell before release sometimes lets it through), but onRefresh not firing was consistent across all 14 trials with RN's RefreshControl.

Suggested fix

Any of these would have saved the debugging:

  1. Warn in __DEV__ when refreshControl is passed but the cloned ref yields no handlerTag.
  2. Wrap a non-GH refreshControl automatically inside GestureComponents' ScrollView/FlatList.
  3. Document the requirement on the ScrollView page — it currently doesn't mention that refreshControl must come from gesture-handler.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions