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
- Render a gesture-handler
ScrollView with a refreshControl element imported from react-native (code below).
- Run on Android.
- Pull down from the top of the list and release.
onRefresh does not fire — nothing refreshes, and the spinner stays frozen part-way down instead of retracting.
- Tap anywhere on the screen — the spinner snaps back up.
- 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:
- Warn in
__DEV__ when refreshControl is passed but the cloned ref yields no handlerTag.
- Wrap a non-GH
refreshControl automatically inside GestureComponents' ScrollView/FlatList.
- Document the requirement on the
ScrollView page — it currently doesn't mention that refreshControl must come from gesture-handler.
Description
What happens:
ScrollView/FlatListfrom gesture-handler silently disable pull-to-refresh when they are given React Native'sRefreshControlinstead 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:onRefreshnever 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
RefreshControlis the one every RN codebase already imports, andGHScrollViewaccepts 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 theRefreshControlimport.Steps to reproduce
ScrollViewwith arefreshControlelement imported fromreact-native(code below).onRefreshdoes not fire — nothing refreshes, and the spinner stays frozen part-way down instead of retracting.import { RefreshControl } from 'react-native-gesture-handler'and repeat: works correctly every time.Platforms
Android only. iOS is unaffected — RN's
ScrollViewis used there in our setup, so no gesture-handler wrapper is involved.Versions
Tested on an Android emulator: Pixel 6a, API 36 (Android 16), arm64-v8a, 1080×2340 @ 420dpi.
Root cause
ScrollViewinsrc/components/GestureComponents.tsxclones therefreshControlelement to attach its own ref, and makes the scroll handlerwaitForit:That only yields a gesture handler when
refreshControlis gesture-handler's ownRefreshControl(createNativeWrapper(RNRefreshControl, …)), whoseuseImperativeHandleexposeshandlerTag. RN'sRefreshControlis a plain class component: the ref resolves to the class instance, it has nohandlerTag, and thewaitForentry is silently dropped.The single
NativeViewGestureHandlerthen attaches to theReactSwipeRefreshLayout(RN's AndroidScrollViewrenders the SwipeRefreshLayout as its root whenrefreshControlis set), soonPrepare()selectsSwipeRefreshLayoutHook— the hook written for the refresh control's handler, not the scroll handler. ItshandleEventBeforeActivationlooks for aNativeViewGestureHandleron the innerScrollView, finds none (there is only one handler), and returns.The pull therefore ends in a cancel rather than a release, and androidx's
SwipeRefreshLayoutignoresACTION_CANCEL— it never callsfinishSpinner(), somRefreshingstays false and the circle is never animated back. The nextACTION_DOWNresets it viaonInterceptTouchEvent, which is why a tap appears to "fix" it.Measured
Injected drags, identical gesture each trial:
onRefreshfiredScrollView+ RNRefreshControlScrollView+ RNRefreshControlScrollView+ GHRefreshControlFlatList+ RNRefreshControlFlatList+ GHRefreshControlThe parked-spinner outcome is timing-dependent (a longer dwell before release sometimes lets it through), but
onRefreshnot firing was consistent across all 14 trials with RN'sRefreshControl.Suggested fix
Any of these would have saved the debugging:
__DEV__whenrefreshControlis passed but the cloned ref yields nohandlerTag.refreshControlautomatically insideGestureComponents'ScrollView/FlatList.ScrollViewpage — it currently doesn't mention thatrefreshControlmust come from gesture-handler.