fix(mobile): surface load-earlier failures and stop scroll-retry storms

A failed older-history page silently did nothing: the near-top scroll
trigger kept re-firing the same doomed request, and a rejected transport
request escaped as an unhandled rejection.

Failures (RPC not-ok, error results, rejections) now set an inline
"Couldn't load earlier messages / Tap to retry" header row, and the
scroll trigger stands down until the user retries explicitly or an
authoritative window supersedes the failed page.

Refs STA-3333.
This commit is contained in:
Brennan Benson
2026-08-04 15:19:38 -07:00
parent 847c8c852d
commit a4aef5bd18
6 changed files with 277 additions and 17 deletions
@@ -73,6 +73,7 @@ export function MobileNativeChatOverlay({
onOpenFile={controller.handleNativeChatOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
loadEarlierError={session.loadEarlierError}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
pending={controller.chatPending}
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, isValidElement, type ReactElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
@@ -65,6 +65,10 @@ type Overrides = {
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
hasMore?: boolean
loadingEarlier?: boolean
loadEarlierError?: string | null
onLoadEarlier?: () => void
onSend?: (text: string) => Promise<boolean>
}
@@ -145,6 +149,39 @@ describe('MobileNativeChatView', () => {
.join('')
}
// The list is a string stand-in, so its header lives as an unrendered element
// on the prop; read it directly rather than through the rendered tree.
function loadEarlierHeader(): ReactElement<Record<string, unknown>> {
const list = renderer!.root.find((node) => node.type === 'FlatList')
return list.props.ListHeaderComponent as ReactElement<Record<string, unknown>>
}
function headerText(element: unknown): string {
if (typeof element === 'string') {
return element
}
if (Array.isArray(element)) {
return element.map((entry) => headerText(entry)).join(' ')
}
if (isValidElement<{ children?: unknown }>(element)) {
return headerText(element.props.children)
}
return ''
}
function scrollToTop(): void {
const list = renderer!.root.find((node) => node.type === 'FlatList')
act(() => {
list.props.onScroll({
nativeEvent: {
contentOffset: { y: 0 },
contentSize: { height: 1000 },
layoutMeasurement: { height: 400 }
}
})
})
}
async function pressSend(): Promise<void> {
const composer = renderer!.root.find((node) => node.type === 'Composer') as {
props: { onPress: () => Promise<boolean> }
@@ -197,4 +234,46 @@ describe('MobileNativeChatView', () => {
expect(listIds()).toEqual(['a1', 'streaming'])
})
it('pages in older history automatically near the top', async () => {
const onLoadEarlier = vi.fn()
await render({ hasMore: true, onLoadEarlier })
scrollToTop()
expect(onLoadEarlier).toHaveBeenCalledOnce()
})
it('blocks automatic paging but keeps explicit retry after loading earlier fails', async () => {
const onLoadEarlier = vi.fn()
await render({
hasMore: true,
loadEarlierError: 'Couldn’t load earlier messages',
onLoadEarlier
})
scrollToTop()
scrollToTop()
expect(onLoadEarlier).not.toHaveBeenCalled()
act(() => (loadEarlierHeader().props.onPress as () => void)())
expect(onLoadEarlier).toHaveBeenCalledOnce()
})
it('surfaces the failure and a retry affordance in the header row', async () => {
await render({ hasMore: true, loadEarlierError: 'Couldn’t load earlier messages' })
const header = loadEarlierHeader()
expect(headerText(header)).toContain('Couldn’t load earlier messages')
expect(headerText(header)).toContain('Tap to retry')
expect(header.props.accessibilityLabel).toBe('Couldn’t load earlier messages. Tap to retry')
})
it('keeps the header tappable-but-idle while a page is in flight', async () => {
await render({ hasMore: true, loadingEarlier: true, onLoadEarlier: vi.fn() })
const header = loadEarlierHeader()
expect(header.props.disabled).toBe(true)
expect(headerText(header)).not.toContain('Tap to retry')
})
})
+20 -3
View File
@@ -54,6 +54,9 @@ type Props = {
streaming: string | null
hasMore?: boolean
loadingEarlier?: boolean
/** Last older-history page failed; shows an inline retry in the header row and
* stops the near-top scroll trigger from re-firing against the same failure. */
loadEarlierError?: string | null
onLoadEarlier?: () => void
onSend: (text: string) => Promise<boolean>
/** Optimistic queued sends (owned by the route so they survive view switches). */
@@ -113,6 +116,7 @@ export function MobileNativeChatView({
streaming,
hasMore,
loadingEarlier,
loadEarlierError,
onLoadEarlier,
onSend,
pending,
@@ -213,12 +217,14 @@ export function MobileNativeChatView({
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent
const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height)
setAtBottom(distanceFromBottom < 80)
// Near the top — page in older history.
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
// Near the top — page in older history. A failed page stops this: the user
// stays near the top, so every later scroll frame would re-fire the same
// doomed request. Only the header's explicit retry clears it.
if (contentOffset.y < 60 && hasMore && !loadingEarlier && !loadEarlierError) {
onLoadEarlier?.()
}
},
[hasMore, loadingEarlier, onLoadEarlier]
[hasMore, loadingEarlier, loadEarlierError, onLoadEarlier]
)
// Align a single message's top to the top of the viewport.
@@ -302,9 +308,20 @@ export function MobileNativeChatView({
style={styles.loadEarlier}
onPress={onLoadEarlier}
disabled={loadingEarlier}
accessibilityRole="button"
accessibilityLabel={
loadEarlierError
? `${loadEarlierError}. Tap to retry`
: 'Load earlier messages'
}
>
{loadingEarlier ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : loadEarlierError ? (
<>
<Text style={styles.loadEarlierErrorText}>{loadEarlierError}</Text>
<Text style={styles.loadEarlierRetryText}>Tap to retry</Text>
</>
) : (
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
)}
@@ -105,5 +105,16 @@ export const styles = StyleSheet.create({
color: colors.textMuted,
fontSize: typography.metaSize,
fontWeight: '600'
},
loadEarlierErrorText: {
color: colors.statusRed,
fontSize: typography.metaSize,
fontWeight: '600'
},
loadEarlierRetryText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '700',
marginTop: spacing.xs
}
})
@@ -174,6 +174,144 @@ describe('useMobileNativeChatSession', () => {
}
)
it('surfaces a failed older-history page and clears it on a retry', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({ ok: false, error: 'offline' })
.mockResolvedValueOnce({
ok: true,
result: { messages: [message('older')], hasMore: false, beforeOffset: 0 }
})
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
expect(state?.loadingEarlier).toBe(false)
// The failure must not consume the page budget — the retry asks for the same
// cursor, not the one after it.
expect(state?.hasMore).toBe(true)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(state?.loadEarlierError).toBeNull()
expect(state?.messages.map((entry) => entry.id)[0]).toBe('older')
expect(sendRequest.mock.calls[1]?.[1]).toEqual(sendRequest.mock.calls[0]?.[1])
})
it('surfaces an error result from an older-history page', async () => {
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { error: 'read failed' } })
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
})
it('surfaces a rejected older-history request instead of leaking it', async () => {
const sendRequest = vi.fn().mockRejectedValue(new Error('disconnected'))
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
expect(state?.loadingEarlier).toBe(false)
})
it('drops a failure from a page the session swapped out from under', async () => {
let rejectEarlier: (reason: unknown) => void = () => {}
const sendRequest = vi.fn(
() => new Promise((_resolve, reject) => (rejectEarlier = reject))
) as unknown as RpcClient['sendRequest']
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () =>
emit({ type: 'replacement', messages: [message('fresh')], hasMore: true, beforeOffset: 0 })
)
await act(async () => {
rejectEarlier(new Error('disconnected'))
await Promise.resolve()
})
// The fresh window already superseded that page; a stale failure must not
// block paging on it.
expect(state?.loadEarlierError).toBeNull()
})
it('clears a surfaced failure when an authoritative window replaces the transcript', async () => {
const sendRequest = vi.fn().mockResolvedValue({ ok: false, error: 'offline' })
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(state?.loadEarlierError).toBe('Couldn’t load earlier messages')
await act(async () =>
emit({ type: 'replacement', messages: [message('fresh')], hasMore: true, beforeOffset: 0 })
)
expect(state?.loadEarlierError).toBeNull()
})
it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => {
let resolveCursorPage: (response: unknown) => void = () => {}
const sendRequest = vi
@@ -24,6 +24,8 @@ export type MobileNativeChatSession = {
hasMore: boolean
/** Whether an older-history page is currently loading. */
loadingEarlier: boolean
/** Set when the last older-history page failed; retry via `loadEarlier`. */
loadEarlierError: string | null
/** Grow the window to page in older history. */
loadEarlier: () => void
}
@@ -36,6 +38,8 @@ const INITIAL_LIMIT = 40
const PAGE = 60
const MAX_MESSAGES = 2000
const LOAD_EARLIER_ERROR = 'Couldn’t load earlier messages'
type ReadSessionResult =
| { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number }
| { error: string }
@@ -81,6 +85,7 @@ export function useMobileNativeChatSession(args: {
const [error, setError] = useState<string | undefined>(undefined)
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [loadEarlierError, setLoadEarlierError] = useState<string | null>(null)
const loadingEarlierRef = useRef(false)
const beforeOffsetRef = useRef<number | null>(null)
// Stateful id-dedup merger: caches the id→index map so each live append frame
@@ -108,6 +113,7 @@ export function useMobileNativeChatSession(args: {
limitRef.current = INITIAL_LIMIT
loadingEarlierRef.current = false
setLoadingEarlier(false)
setLoadEarlierError(null)
setList([])
setError(undefined)
setHasMore(false)
@@ -140,6 +146,8 @@ export function useMobileNativeChatSession(args: {
limitRef.current = INITIAL_LIMIT
loadingEarlierRef.current = false
setLoadingEarlier(false)
// The fresh window supersedes the failed page, so stop blocking paging.
setLoadEarlierError(null)
}
const replaceSnapshot = frame.type === 'snapshot'
const applied = applyMobileNativeChatStreamFrame({
@@ -198,6 +206,9 @@ export function useMobileNativeChatSession(args: {
const beforeOffset = beforeOffsetRef.current
loadingEarlierRef.current = true
setLoadingEarlier(true)
setLoadEarlierError(null)
const requestIsCurrent = (): boolean =>
sessionIdRef.current === requestSessionId && streamGenerationRef.current === requestGeneration
void (async () => {
try {
const response = await client.sendRequest('nativeChat.readSession', {
@@ -207,18 +218,17 @@ export function useMobileNativeChatSession(args: {
...(beforeOffset === null ? {} : { beforeOffset }),
...(transcriptPath ? { transcriptPath } : {})
})
if (!response.ok) {
return
}
const result = response.result as ReadSessionResult
if ('error' in result) {
const result = response.ok ? (response.result as ReadSessionResult) : null
if (!result || 'error' in result) {
// Surface the failed page: doing nothing silently reads as a dead
// scroll, and the header row is the only retry affordance.
if (requestIsCurrent()) {
setLoadEarlierError(LOAD_EARLIER_ERROR)
}
return
}
// Drop a stale resolve from a session that swapped underneath us.
if (
sessionIdRef.current !== requestSessionId ||
streamGenerationRef.current !== requestGeneration
) {
if (!requestIsCurrent()) {
return
}
limitRef.current = nextLimit
@@ -233,12 +243,15 @@ export function useMobileNativeChatSession(args: {
setList(result.messages)
setHasMore(result.messages.length >= nextLimit)
}
} catch {
// A rejected transport request (disconnect mid-page) is a failed page too;
// without this it also escaped as an unhandled rejection.
if (requestIsCurrent()) {
setLoadEarlierError(LOAD_EARLIER_ERROR)
}
} finally {
// A late page from a prior tab must not unlock the current tab's request.
if (
sessionIdRef.current === requestSessionId &&
streamGenerationRef.current === requestGeneration
) {
if (requestIsCurrent()) {
loadingEarlierRef.current = false
setLoadingEarlier(false)
}
@@ -255,6 +268,7 @@ export function useMobileNativeChatSession(args: {
error,
hasMore,
loadingEarlier,
loadEarlierError,
loadEarlier
}
}