Move Interceptor on ScrollView, not its content#4331
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a regression in v3 ScrollView/FlatList sticky header behavior introduced by wrapping all ScrollView children into a single logical responder node, which caused stickyHeaderIndices to pin the entire content (or become a no-op for indices > 0). The fix wraps each child individually while preserving the responder-interception behavior via a shared context provider.
Changes:
- Replace the single “logical responder” wrapper with per-child logical wrappers (
interceptScrollViewChildren) sostickyHeaderIndicesmap to the intended children. - Split the interceptor into a context provider (
ScrollViewResponderProvider) plus per-child wrappers (LogicalResponderChild), and apply this in the v3ScrollViewcomponent.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx | Refactors responder interception into a provider + per-child wrapper and adds interceptScrollViewChildren. |
| packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx | Updates v3 ScrollView to use the provider and wrap children individually via interceptScrollViewChildren. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return ( | ||
| <JSResponderContext value={contextValue}>{children}</JSResponderContext> | ||
| ); |
There was a problem hiding this comment.
I don't think React <19 is a concern
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| {children} | ||
| </View> | ||
| </JSResponderContext> | ||
| <View |
There was a problem hiding this comment.
You're rendering a new non-flattenable view for each ScrollView child. This will measurably impact performance.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx:181
onResponderReleasealso assumes it always receives an event and will throw if it’s called without one whileclaimedForKeyboardDismissalis true (same test pattern asonStartShouldSetResponder). Making the event parameter optional and guarding before readingevent.targetavoids a potential crash in tests and keeps the handler defensive.
const handleResponderRelease = useCallback((event: GestureResponderEvent) => {
if (!claimedForKeyboardDismissal.current) {
return;
}
claimedForKeyboardDismissal.current = false;
const currentlyFocusedInput = TextInput.State.currentlyFocusedInput();
if (
currentlyFocusedInput != null &&
keyboardIsDismissible() &&
event.target !== currentlyFocusedInput
) {
TextInput.State.blurTextInput(currentlyFocusedInput);
}
}, []);
j-piasecki
left a comment
There was a problem hiding this comment.
The title seems incorrect after the changes
| {...rest} | ||
| ref={props.ref} | ||
| keyboardShouldPersistTaps={keyboardShouldPersistTaps} | ||
| // In 'handled' mode the provider above owns the keyboard dismissal, so |
There was a problem hiding this comment.
What about always and never?
There was a problem hiding this comment.
In never RN grabs the touch during capture, before children even see it, so there's no conflict with our marks (press suppression comes from isKeyboardDismissingTap, #4281). Disabling the responder there would only break the dismissal.
In always RN does nothing keyboard-related, so there's nothing to intercept. Only handled has the bubble claim that conflicted with the marks, which is why both the flag and the provider's handlers are gated on it.
| <GHScrollView | ||
| {...rest} | ||
| ref={props.ref} | ||
| keyboardShouldPersistTaps={keyboardShouldPersistTaps} |
There was a problem hiding this comment.
Does this work with the scrollview's responder disabled? From what I can see in the implementation almost all references to it are in responder callbacks.
There was a problem hiding this comment.
Yeah, it works — the flag only kills the handled bubble claim and its blur-on-release, which is exactly what the provider reimplements. Everything else that reads keyboardShouldPersistTaps is either the never-only capture path (no-op in handled) or the tap-while-decelerating claim, which sits before the disable check and keeps working.
My Claude instrumented it to be sure: with the flag on, a tap during deceleration still hits isAnimating=true, the ScrollView claims it, keyboard stays open, identical to plain RN ScrollView next to it. never/always don't set the flag, so they're fully stock.
Only known gap: our onResponderTerminate stand-in for _observedScrollSinceBecomingResponder doesn't cover a tap landing in the tiny window right after deceleration ends - stock would skip the blur there, we wouldn't. Couldn't trigger it on purpose though.
ScrollView interceptor directly to childrenScrollView, not its content
Description
#4158 introduced
ScrollViewResponderInterceptor, which wraps allScrollViewchildren in a single logical-responderView. RN'sScrollView.render()resolvesstickyHeaderIndicesagainstReact.Children.toArray(this.props.children)and wraps the child at each sticky index inScrollViewStickyHeader, which counter-translates that child against the scroll offset. With the content collapsed into one child, index0is the entire content — so everything gets pinned. Indices > 0 were a silent no-op (they pointed past the single child).To fix this, we now move the wrapper outside of
ScrollViewand handle keyboard dismissal manually.Fixes #4328
Test plan
Tested on the following code: