From 92ef7be3a6e89f872fa545f4ab35b73770f29b8d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:35:19 -0700 Subject: [PATCH] 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 --- ...ile-session-terminal-subscription.test.tsx | 98 +++++++++++++++++++ ...se-mobile-session-terminal-subscription.ts | 8 +- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx b/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx index 44484d413b5..5bf6aedf038 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx +++ b/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx @@ -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) + } + } + ) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 20ef8315b1f..9a1233a5235 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -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