fix(mobile): keep the viewport steady when chat history pages in

Tapping "Load earlier messages" grew the list from the top while the
viewport stayed at a fixed offset, so the reader was thrown into the
middle of the newly-prepended page.

Anchor the FlatList with maintainVisibleContentPosition so a prepend
grows upward, and hold the two tail-follow paths (the post-commit
autoscroll effect and onContentSizeChange) for the duration of the
page-in. The hold matters on short transcripts, where the reader is
simultaneously at the top and `atBottom` — there the autoscroll would
otherwise immediately undo the anchoring.

Refs STA-3333.
This commit is contained in:
Brennan Benson
2026-08-04 01:59:12 -07:00
parent e9cf106769
commit 7bd313a1fd
2 changed files with 100 additions and 11 deletions
@@ -3,14 +3,22 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatView } from './MobileNativeChatView'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: 'FlatList',
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
const { scrollToEnd } = vi.hoisted(() => ({ scrollToEnd: vi.fn() }))
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
FlatList: React.forwardRef((props: object, ref) => {
React.useImperativeHandle(ref, () => ({ scrollToEnd }))
return React.createElement('FlatList', props)
}),
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}
})
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
@@ -58,9 +66,12 @@ vi.mock('./MobileNativeChatComposer', async () => {
})
type Overrides = {
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
hasMore?: boolean
loadingEarlier?: boolean
onSend?: (text: string) => Promise<boolean>
}
@@ -75,11 +86,12 @@ function suppressRendererWarning(): () => void {
return () => spy.mockRestore()
}
describe('MobileNativeChatView send-error banner', () => {
describe('MobileNativeChatView', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
scrollToEnd.mockClear()
})
afterEach(() => {
@@ -108,6 +120,22 @@ describe('MobileNativeChatView send-error banner', () => {
}
}
async function update(overrides: Overrides = {}): Promise<void> {
await act(async () => {
renderer?.update(
createElement(MobileNativeChatView, {
messages: [],
status: 'ready',
onSend: vi.fn().mockResolvedValue(true),
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
...overrides
})
)
})
}
function banners(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
@@ -161,4 +189,52 @@ describe('MobileNativeChatView send-error banner', () => {
expect(onClearSendError).toHaveBeenCalledOnce()
})
const message = {
id: 'current',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: 'current' }],
timestamp: 0,
source: 'transcript' as const
}
const older = { ...message, id: 'older', blocks: [{ type: 'text' as const, text: 'older' }] }
it('does not defeat prepend anchoring when a short list was also at bottom', async () => {
vi.useFakeTimers()
try {
await render({ messages: [message], hasMore: true })
await act(async () => vi.runAllTimersAsync())
scrollToEnd.mockClear()
await update({ messages: [message], hasMore: true, loadingEarlier: true })
await update({ messages: [older, message], hasMore: false, loadingEarlier: false })
const list = renderer!.root.find((node) => node.type === 'FlatList')
expect(list.props.maintainVisibleContentPosition).toEqual({ minIndexForVisible: 0 })
act(() => list.props.onContentSizeChange())
await act(async () => vi.runAllTimersAsync())
expect(scrollToEnd).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('still follows the tail once the history page has settled', async () => {
vi.useFakeTimers()
try {
await render({ messages: [older, message], hasMore: false })
await act(async () => vi.runAllTimersAsync())
scrollToEnd.mockClear()
const appended = { ...message, id: 'appended' }
await update({ messages: [older, message, appended], hasMore: false })
const list = renderer!.root.find((node) => node.type === 'FlatList')
act(() => list.props.onContentSizeChange())
await act(async () => vi.runAllTimersAsync())
expect(scrollToEnd).toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
+15 -2
View File
@@ -151,6 +151,10 @@ export function MobileNativeChatView({
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
const [atBottom, setAtBottom] = useState(true)
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// A short transcript leaves the reader both at the top and `atBottom`, so the
// tail-follow paths would scroll away the history that just paged in.
const keepHistoryPositionRef = useRef(Boolean(loadingEarlier))
const keepHistoryPosition = keepHistoryPositionRef.current
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
useEffect(
() => () => {
@@ -176,12 +180,17 @@ export function MobileNativeChatView({
// we don't yank the user away while they read history. (Also fires on keyboard
// close, which is harmless while atBottom.)
useEffect(() => {
if (data.length === 0 || !atBottom) {
if (data.length === 0 || !atBottom || keepHistoryPositionRef.current) {
return
}
const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60)
return () => clearTimeout(t)
}, [data.length, atBottom, keyboardInset])
// Must stay below the tail-follow effect: the prepend lands in the same commit
// that clears `loadingEarlier`, and that effect has to still see the hold.
useEffect(() => {
keepHistoryPositionRef.current = Boolean(loadingEarlier)
}, [loadingEarlier])
const handleSend = useCallback(
async (text: string): Promise<boolean> => {
@@ -274,8 +283,12 @@ export function MobileNativeChatView({
contentContainerStyle={styles.listContent}
onScroll={onScroll}
scrollEventThrottle={32}
// Grow the list upward on a loadEarlier prepend instead of jumping
// the reader. Index 0 is the first message — FlatList accounts for
// the load-earlier header offset internally.
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
onContentSizeChange={() => {
if (data.length > 0 && atBottom) {
if (data.length > 0 && atBottom && !keepHistoryPosition) {
listRef.current?.scrollToEnd({ animated: false })
}
}}