Always scroll to bottom of chat after sending a message (#10913)

This commit is contained in:
DS Boyce
2026-06-30 09:38:06 -07:00
committed by GitHub
parent 95726c6c6b
commit bb20f234f3
@@ -200,6 +200,21 @@ export function MessagesList({
// the bottom.
const isAtBottom = useSharedValue(true)
// Set when the local user sends a message so we follow it to the end even
// from a scrolled-up position. On native, onContentSizeChange can't be relied
// on: with maintainVisibleContentPosition anchored to item 0, appending a
// message below the viewport reports no content-size change, so that callback
// never fires for the send. We watch the rendered item count instead and
// scroll imperatively once our pending message lands (APP-2223). On web,
// onContentSizeChange also fires for the send, so the scroll may run from both
// paths - both target the end, so the result is correct.
const pendingSendScroll = useRef(false)
// Handle for the in-flight send-scroll burst (see startSendScrollBurst). Held
// here so the re-init effect below can cancel a burst that belongs to the
// previous convo lifecycle.
const sendScrollRaf = useRef(0)
// This will be used on web to assist in determining if we need to maintain the content offset
const isAtTop = useSharedValue(true)
@@ -220,6 +235,12 @@ export function MessagesList({
if (prevHasScrolled.current && !hasScrolled) {
hasInitiallyScrolled.current = false
setDidInitialScroll(false)
// Drop any unfired send pin and stop an in-flight scroll burst: the
// initial-scroll path owns positioning during re-init, and the pending
// message they referred to belongs to the previous lifecycle.
pendingSendScroll.current = false
cancelAnimationFrame(sendScrollRaf.current)
sendScrollRaf.current = 0
}
prevHasScrolled.current = hasScrolled
}, [hasScrolled])
@@ -253,6 +274,59 @@ export function MessagesList({
}
}, [convoState.status])
// Scroll to a saturating offset rather than scrollToEnd: when the keyboard is
// open, KeyboardChatScrollView lifts the content via extraContentPadding, and
// scrollToEnd's internal target is unaware of that lift, so it lands short by
// the keyboard height. An over-large offset clamps to the true bottom.
const scrollSendToBottom = useCallback(() => {
flatListRef.current?.scrollToOffset({
offset: Number.MAX_SAFE_INTEGER,
animated: true,
})
}, [flatListRef])
// A single scroll can't follow a send to the bottom: a multi-line send settles
// over several layout passes (the tall pending item being measured, then the
// composer collapsing back to one line), and the content bottom keeps moving
// after the scroll target was clamped. We can't drive this off the composer's
// height drop either - that signal is global, outlives the send, and races the
// pending-message append. Instead we re-assert the saturating scroll across a
// short window keyed to the send. Each call re-clamps to the *current* true
// bottom, so the last one lands settled regardless of how many passes it took.
// The burst is bounded and self-terminating, so it can't leak into a later
// unrelated resize, and it polls geometry rather than depending on
// onContentSizeChange (which doesn't fire for the send append on native - see
// the pendingSendScroll declaration).
const stopSendScrollBurst = useCallback(() => {
cancelAnimationFrame(sendScrollRaf.current)
sendScrollRaf.current = 0
}, [])
const startSendScrollBurst = useCallback(() => {
stopSendScrollBurst()
const deadline = Date.now() + 200
const tick = () => {
scrollSendToBottom()
sendScrollRaf.current =
Date.now() < deadline ? requestAnimationFrame(tick) : 0
}
tick()
}, [scrollSendToBottom, stopSendScrollBurst])
// Cancel any in-flight burst on unmount.
useEffect(() => stopSendScrollBurst, [stopSendScrollBurst])
// Follow a just-sent message to the end. This runs when the rendered item
// count changes, but only fires once the tail item is our own optimistic
// pending message (pending-message items are local-only). That way a foreign
// message arriving between send and our append doesn't consume the pin or yank
// a scrolled-up reader down to it - the pin waits for our message to land.
useEffect(() => {
if (!pendingSendScroll.current) return
if (renderItems.at(-1)?.type !== 'pending-message') return
pendingSendScroll.current = false
startSendScrollBurst()
}, [renderItems, startSendScrollBurst])
// -- Scroll handling
// Every time the content size changes, that means one of two things is happening:
@@ -479,6 +553,11 @@ export function MessagesList({
setHasScrolled(true)
}
// Sending your own message should always take you to it, regardless of
// current scroll position. The effect watching renderItems.length scrolls
// to the end once the pending message is appended.
pendingSendScroll.current = true
convoState.sendMessage(
{
text: rt.text,