Android: KeyboardAwareLegendList teleports scroll to offset 0 ~7 ms after keyboardDidShow on a freshly-mounted list
Summary
On Android, when a KeyboardAwareLegendList (a LegendList wrapped with @legendapp/list/keyboard, backed by this library's KeyboardChatScrollView) is at max scroll and the soft keyboard opens, the list occasionally teleports its scroll position from max_scroll to 0 in a single non-animated jump about 7–13 ms after keyboardDidShow fires. This puts the user at the top of the currently-loaded window instead of keeping the keyboard-aware "content follows the keyboard up" behavior. iOS (which uses the separate useChatKeyboard/index.ios.ts path) is not affected.
The scroll jump does not go through the imperative LegendListRef (verified by monkey-patching scrollToEnd / scrollToOffset / scrollToIndex on the ref — none fire), and no data change is involved (verified by logging flatReplies length changes and onStartReached). The scroll is being moved from inside the wrapped scrollview stack.
Environment
react-native-keyboard-controller: 1.21.8
@legendapp/list: 3.3.3
react-native: 0.81.5
react-native-reanimated: 4.1.6
react-native-worklets: 0.5.1
expo: 54.0.36 (dev-build, edge-to-edge enabled, windowSoftInputMode="adjustResize")
- Fabric
Reproduction conditions (all required)
- Long list with more items available upstream (
canLoadMore.previous === true).
- List is at max_scroll (bottom-mounted via
initialScrollAtEnd={true}).
<KeyboardAwareLegendList> inside a <KeyboardGestureArea enableSwipeToDismiss={false}>, with a first-party focusable input outside the list (WebView-based rich editor in our case, but I suspect a plain TextInput reproduces it).
- User focuses the input, keyboard opens.
The jump is significantly more likely on a freshly-mounted list (the ChatList is key-remounted when the user changes anchor position), but has also been observed on cold entries with the same key.
The bug does not appear if any of the following is true:
- List is short enough that there's nothing to fetch upward.
canLoadMore.previous is false.
- User has been on this mount for a while and no in-thread jump preceded the input tap.
- Platform is iOS.
Instrumented log evidence
Three consecutive attempts on Android, tapping the reply input while the list is at max_scroll:
[ThreadView] keyboardDidShow {"scrollOffset":3364,"estimatedMaxScroll":3364,"listReady":true,"msSinceListReady":9236, "layoutHeight":536,"contentHeight":3900}
[ThreadView] post-keyboard scroll jump {"from":3364,"to":0,"delta":-3364,"msSinceKeyboard":7, "layoutHeight":536,"contentHeight":3900}
[ThreadView] post-keyboard scroll jump {"from":9, "to":229,"delta":220, "msSinceKeyboard":205,"layoutHeight":451,"contentHeight":3900}
[ThreadView] keyboardDidShow {"scrollOffset":3281,"estimatedMaxScroll":3281,"listReady":true,"msSinceListReady":17152}
[ThreadView] post-keyboard scroll jump {"from":3281,"to":0,"delta":-3281,"msSinceKeyboard":7}
[ThreadView] post-keyboard scroll jump {"from":9, "to":229,"delta":220, "msSinceKeyboard":213}
[ThreadView] keyboardDidShow {"scrollOffset":3070,"estimatedMaxScroll":3070,"listReady":true,"msSinceListReady":7257}
[ThreadView] post-keyboard scroll jump {"from":3070,"to":0,"delta":-3070,"msSinceKeyboard":13}
[ThreadView] post-keyboard scroll jump {"from":9, "to":229,"delta":220, "msSinceKeyboard":216}
Observations:
- At
keyboardDidShow, scrollOffset === estimatedMaxScroll — list is fully at the bottom, in the expected position, and onLoad had long since fired.
- ~7–13 ms later,
onScroll reports a negative delta equal to the entire scroll range, landing at offset 0. Not a smooth animation — a discrete scrollTo-like jump.
- ~200 ms later, a second small delta (+220) suggests
useChatKeyboard.onMove runs its normal clampedScrollTarget math against the now-broken offsetBeforeScroll, arriving at ~keyboard_height from top instead of ~keyboard_height above the previous position.
The user visually experiences this as "list flew to the top and now shows the load-previous header, and I've lost my place in the thread."
What I've ruled out
maintainVisibleContentPosition: setting maintainVisibleContentPosition={false} on the outer <LegendList> does not change the behavior.
onStartReached / loadPreviousPage: instrumented; the callback does not fire around the scroll-jump window. No flatReplies length change either.
- Our own imperative scroll calls: monkey-patched
LegendListRef.scrollToEnd/scrollToOffset/scrollToIndex — none of them log during the 7–13 ms window. The scroll is happening inside the wrapper stack, not via the exposed ref.
- Race with
initialScrollAtEnd: msSinceListReady is 7–17 seconds by the time this fires — LegendList has been fully positioned for a long time before the tap.
Suspected area
useChatKeyboard/index.ts (Android/default path) — likely onStart capturing an offsetBeforeScroll that doesn't reflect the actual bottom-anchored scroll offset (maybe reading scroll.value on an useAnimatedRef before Android's native scroll state has propagated back to the UI thread's shared value on a remounted list?), then subsequently scrollTo(scrollViewRef, 0, 0, false) from a code path that shouldn't be reached.
But I stopped short of forking/patching to instrument the library directly — happy to run further tests on the library side if that helps.
What I can do to help
- Provide a minimal repro app if that speeds things up (currently the repro is inside a full app; I can extract it).
- Run additional logs anywhere in the library you'd like me to instrument.
- Test candidate fixes.
Thanks for the library.
Android:
KeyboardAwareLegendListteleports scroll to offset 0 ~7 ms afterkeyboardDidShowon a freshly-mounted listSummary
On Android, when a
KeyboardAwareLegendList(aLegendListwrapped with@legendapp/list/keyboard, backed by this library'sKeyboardChatScrollView) is at max scroll and the soft keyboard opens, the list occasionally teleports its scroll position frommax_scrollto0in a single non-animated jump about 7–13 ms afterkeyboardDidShowfires. This puts the user at the top of the currently-loaded window instead of keeping the keyboard-aware "content follows the keyboard up" behavior. iOS (which uses the separateuseChatKeyboard/index.ios.tspath) is not affected.The scroll jump does not go through the imperative
LegendListRef(verified by monkey-patchingscrollToEnd/scrollToOffset/scrollToIndexon the ref — none fire), and no data change is involved (verified by loggingflatReplieslength changes andonStartReached). The scroll is being moved from inside the wrapped scrollview stack.Environment
react-native-keyboard-controller:1.21.8@legendapp/list:3.3.3react-native:0.81.5react-native-reanimated:4.1.6react-native-worklets:0.5.1expo:54.0.36(dev-build, edge-to-edge enabled,windowSoftInputMode="adjustResize")Reproduction conditions (all required)
canLoadMore.previous === true).initialScrollAtEnd={true}).<KeyboardAwareLegendList>inside a<KeyboardGestureArea enableSwipeToDismiss={false}>, with a first-party focusable input outside the list (WebView-based rich editor in our case, but I suspect a plainTextInputreproduces it).The jump is significantly more likely on a freshly-mounted list (the ChatList is
key-remounted when the user changes anchor position), but has also been observed on cold entries with the same key.The bug does not appear if any of the following is true:
canLoadMore.previousis false.Instrumented log evidence
Three consecutive attempts on Android, tapping the reply input while the list is at max_scroll:
Observations:
keyboardDidShow,scrollOffset === estimatedMaxScroll— list is fully at the bottom, in the expected position, andonLoadhad long since fired.onScrollreports a negative delta equal to the entire scroll range, landing at offset 0. Not a smooth animation — a discretescrollTo-like jump.useChatKeyboard.onMoveruns its normalclampedScrollTargetmath against the now-brokenoffsetBeforeScroll, arriving at~keyboard_heightfrom top instead of~keyboard_heightabove the previous position.The user visually experiences this as "list flew to the top and now shows the load-previous header, and I've lost my place in the thread."
What I've ruled out
maintainVisibleContentPosition: settingmaintainVisibleContentPosition={false}on the outer<LegendList>does not change the behavior.onStartReached/loadPreviousPage: instrumented; the callback does not fire around the scroll-jump window. NoflatReplieslength change either.LegendListRef.scrollToEnd/scrollToOffset/scrollToIndex— none of them log during the 7–13 ms window. The scroll is happening inside the wrapper stack, not via the exposed ref.initialScrollAtEnd:msSinceListReadyis 7–17 seconds by the time this fires — LegendList has been fully positioned for a long time before the tap.Suspected area
useChatKeyboard/index.ts(Android/default path) — likelyonStartcapturing anoffsetBeforeScrollthat doesn't reflect the actual bottom-anchored scroll offset (maybe readingscroll.valueon anuseAnimatedRefbefore Android's native scroll state has propagated back to the UI thread's shared value on a remounted list?), then subsequentlyscrollTo(scrollViewRef, 0, 0, false)from a code path that shouldn't be reached.But I stopped short of forking/patching to instrument the library directly — happy to run further tests on the library side if that helps.
What I can do to help
Thanks for the library.