fix(mobile): retain terminal reading position after resume (#31661)

Preserve terminal reading position on foreground and reconnect by reusing the existing scroll-preservation option. Adapted from community PR #23401 by Shahar Mor (@shaharmor), with native iOS before/after proof and 38 focused regression tests.

Co-authored-by: Shahar Mor <shaharmor1@gmail.com>
This commit is contained in:
Neil
2026-10-21 14:35:19 -07:00
committed by GitHub
co-authored by Shahar Mor
parent e728d2ccac
commit 92ef7be3a6
2 changed files with 101 additions and 5 deletions
@@ -1,4 +1,16 @@
// @vitest-environment happy-dom
import { Terminal } from '@xterm/xterm'
import { createElement } from 'react'
import { createTerminalDocumentScope } from '../terminal/document/document-scope'
import {
startTerminalDocument,
stopTerminalDocument
} from '../terminal/document/create-terminal-document'
import { handleMsg } from '../terminal/document/host-message-router'
import type { TerminalDocumentTerminal } from '../terminal/document/document-terminal-shape'
import { TERMINAL_DOCUMENT_MARKUP } from '../terminal/terminal-webview-html/document-markup'
import { recoverActiveTerminalAfterForeground } from '../terminal/terminal-foreground-recovery'
import type { ConnectionState } from '../transport/types'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
@@ -368,3 +380,89 @@ describe('a stream terminating while subscribe is still returning', () => {
expect(h.fields.signalTerminalInventoryRecovery).toHaveBeenCalledTimes(1)
})
})
describe('foreground snapshots preserve the terminal reading position', () => {
it.each(['connected', 'reconnecting'] as const)(
'restores the reading row after recovery from %s',
async (connectionState) => {
const h = subscriptionHarness({ fit: PHONE, webReady: true, viewport: PHONE })
document.body.innerHTML = TERMINAL_DOCUMENT_MARKUP
let ready = Promise.resolve()
let resolveReady = () => {}
const scope = createTerminalDocumentScope({
installHostTransport: () => () => {},
hasEngine: () => true,
createTerminal: (options) => {
const terminal = new Terminal(options)
terminal.open = () => {}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm implements the document API; its options are initialized by the document.
return terminal as unknown as TerminalDocumentTerminal
},
createWebglAddon: () => null,
createUnicode11Addon: () => null,
postToHost: (message) => {
if (message.type === 'ready') {
resolveReady()
}
}
})
startTerminalDocument(scope)
try {
vi.mocked(h.terminal.init).mockImplementation((options) => {
ready = new Promise((resolve) => {
resolveReady = resolve
})
handleMsg(scope, { type: 'init', ...options })
})
const snapshot = Array.from({ length: 200 }, (_, row) => `LINE_${row}`).join('\r\n')
const replay = async (serialized: unknown = snapshot) => {
h.event(h.fields.client.subscribe.mock.calls.length - 1, {
type: 'scrollback',
cols: PHONE.cols,
rows: PHONE.rows,
serialized
})
await ready
}
h.subscribe()
await replay()
expect(scope.term!.buffer.active.viewportY).toBe(scope.term!.buffer.active.baseY)
scope.term!.scrollToLine(40)
const readingRow = scope.term!.buffer.active.getLine(40)?.translateToString(true)
const connStateRef: { current: ConnectionState } = { current: connectionState }
const scheduled: Array<() => void> = []
const recover = () =>
recoverActiveTerminalAfterForeground({
...h.fields,
connStateRef,
subscribeToTerminal: h.subscribe,
schedule: (callback) => scheduled.push(callback)
})
if (connectionState === 'reconnecting') {
expect(recover()).toBe('deferred')
connStateRef.current = 'connected'
}
expect(recover()).toBe('recovered')
scheduled.splice(0).forEach((callback) => callback())
await replay()
expect(scope.term!.buffer.active.viewportY).toBe(40)
expect(scope.term!.buffer.active.getLine(40)?.translateToString(true)).toBe(readingRow)
scope.term!.scrollToBottom()
recover()
scheduled.splice(0).forEach((callback) => callback())
await replay()
expect(scope.term!.buffer.active.viewportY).toBe(scope.term!.buffer.active.baseY)
scope.term!.scrollToLine(40)
recover()
scheduled.splice(0).forEach((callback) => callback())
await replay('')
expect(scope.term!.buffer.active.viewportY).toBe(0)
expect(scope.term!.buffer.active.baseY).toBe(0)
} finally {
stopTerminalDocument(scope)
}
}
)
})
@@ -192,10 +192,7 @@ export function useMobileSessionTerminalSubscription(
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
const cols = hostCols ?? viewportRef.current?.cols ?? 80
const rows = hostRows ?? viewportRef.current?.rows ?? 24
const initialData =
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
: ''
const initialData = typeof data.serialized === 'string' ? data.serialized : ''
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
const ref = getTerminalRef(handle)
// Why: only mark initialized once init() reaches the WebView, else later scrollback is dropped and the terminal stays blank.
@@ -207,7 +204,8 @@ export function useMobileSessionTerminalSubscription(
})
return
}
ref.init({ cols, rows, initialData, oscLinks, frame: terminalFrameRef.current })
const frame = terminalFrameRef.current
ref.init({ cols, rows, initialData, preserveScroll: true, oscLinks, frame })
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode