fix(mobile): recover terminal state after iOS resume

This commit is contained in:
Jinjing
2026-07-10 18:53:02 -07:00
parent aa9871183f
commit a89a6e756c
19 changed files with 550 additions and 87 deletions
@@ -93,7 +93,7 @@ import type {
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
@@ -2530,8 +2530,12 @@ export default function SessionScreen() {
if (!shouldRecover) {
return
}
for (const terminalRef of terminalRefs.current.values()) {
terminalRef.prepareForForegroundRecovery()
}
// Why: iOS can resume a live WKWebView with a blank xterm backing store
// without firing web-ready/reconnect; replay scrollback to repaint it.
// without firing web-ready/reconnect; invalidate the native readiness
// latch before replay so init waits for the document's pong.
recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
@@ -2559,6 +2563,7 @@ export default function SessionScreen() {
clientRef,
deviceTokenRef,
initializedHandlesRef,
connState,
tabStripVisible: terminals.length > 1,
textScale: terminalTextScale,
terminalFrameWidth,
@@ -1,5 +1,5 @@
import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
import type { MobileTerminalTheme } from '../../../../src/terminal/TerminalWebView'
import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract'
import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines'
import type {
MobileHighlightedDiffLine,
+7 -7
View File
@@ -1,12 +1,12 @@
import { useCallback } from 'react'
import { StyleSheet, View } from 'react-native'
import {
TerminalWebView,
type MobileTerminalTheme,
type TerminalKeyboardAvoidanceMetrics,
type TerminalModes,
type TerminalWebViewHandle
} from '../terminal/TerminalWebView'
import { TerminalWebView } from '../terminal/TerminalWebView'
import type {
MobileTerminalTheme,
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalWebViewHandle
} from '../terminal/terminal-webview-contract'
type TerminalPaneViewProps = {
handle: string
@@ -1,4 +1,4 @@
import type { TerminalModes } from '../terminal/TerminalWebView'
import type { TerminalModes } from '../terminal/terminal-webview-contract'
import type { ConnectionState } from '../transport/types'
export const MOBILE_SESSION_STATUS_LABELS: Record<ConnectionState, string> = {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
getTerminalRecordsFromSessionTabs,
mergeTerminalListWithKnownRecords,
mergeTerminalRecordsByCurrentOrder,
mobileSessionTabsEqual,
type MobileTerminalSessionTab,
type TerminalRecord
@@ -24,6 +25,17 @@ const darkTheme = {
}
describe('mobile terminal records', () => {
it('keeps the known theme when a session-tab snapshot omits it', () => {
const known: TerminalRecord[] = [
{ handle: 'pty-1', title: 'Old title', terminalTheme: darkTheme, isActive: false }
]
const snapshot: TerminalRecord[] = [{ handle: 'pty-1', title: 'Current title', isActive: true }]
expect(mergeTerminalRecordsByCurrentOrder(snapshot, known)).toEqual([
{ handle: 'pty-1', title: 'Current title', terminalTheme: darkTheme, isActive: true }
])
})
it('keeps session-tab terminal themes when terminal.list omits them', () => {
const terminalList: TerminalRecord[] = [
{ handle: 'pty-1', title: 'Terminal', isActive: true },
+13 -2
View File
@@ -1,4 +1,4 @@
import type { MobileTerminalTheme } from '../terminal/TerminalWebView'
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
export type TerminalRecord = {
@@ -126,7 +126,18 @@ export function mergeTerminalRecordsByCurrentOrder(
const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.handle, tab]))
const currentHandles = new Set(currentTerminals.map((terminal) => terminal.handle))
return [
...currentTerminals.map((terminal) => terminalTabsByHandle.get(terminal.handle) ?? terminal),
...currentTerminals.map((terminal) => {
const snapshotTerminal = terminalTabsByHandle.get(terminal.handle)
if (!snapshotTerminal) {
return terminal
}
// Why: lightweight session snapshots can omit the theme; keep the last
// known value while still accepting current title/activity fields.
return {
...snapshotTerminal,
terminalTheme: snapshotTerminal.terminalTheme ?? terminal.terminalTheme
}
}),
...terminalTabs.filter((terminal) => !currentHandles.has(terminal.handle))
]
}
@@ -2,7 +2,7 @@ import { useCallback, type RefObject } from 'react'
import * as Clipboard from 'expo-clipboard'
import { File as FsFile, Paths } from 'expo-file-system'
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
import type { TerminalModes } from '../terminal/TerminalWebView'
import type { TerminalModes } from '../terminal/terminal-webview-contract'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
+64 -36
View File
@@ -1,13 +1,9 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { View, type StyleProp, type ViewStyle } from 'react-native'
import { Platform, View } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import type {
MobileTerminalTheme,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import {
TerminalWebViewEngineErrorOverlay,
useTerminalWebViewEngineErrorState
@@ -18,23 +14,9 @@ import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
export type {
MobileTerminalTheme,
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
type Props = TerminalWebViewProps
type Props = {
style?: StyleProp<ViewStyle>
terminalTheme?: MobileTerminalTheme
// Why: baseline zoom multiplier ("text size") applied on top of the fit-to-width
// scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
textScale?: number
onWebReady?: () => void
onEngineError?: (message: string) => void
} & TerminalSelectionEvents
export type { TerminalWebViewHandle } from './terminal-webview-contract'
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
{
@@ -61,6 +43,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const isWebReadyRef = useRef(false)
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
const messageIdRef = useRef(0)
const pendingPingIdRef = useRef<number | null>(null)
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
const measureResolveRef = useRef<
((result: { cols: number; rows: number } | null) => void) | null
@@ -80,7 +63,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current }))
const id = messageIdRef.current
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id }))
return id
}, [])
const flushPendingMessages = useCallback(() => {
@@ -98,6 +83,30 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
[pendingMessages, sendToWebView]
)
const confirmWebReady = useCallback(
(notifyParent: boolean) => {
pendingPingIdRef.current = null
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
if (notifyParent) {
onWebReady?.()
}
// Why: reload clears queued commands, so readiness must always restore the
// native-selected theme even when its value did not change in React.
sendToWebView({ type: 'set-theme', terminalTheme })
flushPendingMessages()
},
[
clearEngineError,
clearWebReadyWatchdog,
flushPendingMessages,
onWebReady,
sendToWebView,
terminalTheme
]
)
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let msg: Record<string, unknown>
@@ -108,11 +117,13 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
}
if (msg.type === 'web-ready') {
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
onWebReady?.()
flushPendingMessages()
confirmWebReady(true)
} else if (
msg.type === 'pong' &&
typeof msg.pingId === 'number' &&
msg.pingId === pendingPingIdRef.current
) {
confirmWebReady(false)
} else if (msg.type === 'ready') {
// Why: the WebView's init() rAF chain has run — term is open,
// renderService is populated, first paint has happened. Resolve
@@ -208,11 +219,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
}
},
[
flushPendingMessages,
clearEngineError,
clearWebReadyWatchdog,
confirmWebReady,
reportEngineError,
onWebReady,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
@@ -229,6 +237,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
pendingPingIdRef.current = null
armWebReadyWatchdog()
// Why: messages queued for a previous WebView generation are stale after a reload;
// dropping them avoids replaying terminal chunks before the next init snapshot.
@@ -240,6 +249,17 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
webViewRef.current?.reload()
}, [clearEngineError])
const handleContentProcessDidTerminate = useCallback(() => {
// Why: WKWebView content-process loss is recoverable; stale commands belong
// to the dead document and the replacement must prove readiness before replay.
isWebReadyRef.current = false
pendingPingIdRef.current = null
pendingMessages.clear()
clearEngineError()
armWebReadyWatchdog()
webViewRef.current?.reload()
}, [armWebReadyWatchdog, clearEngineError, pendingMessages])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
}, [postMessage, terminalThemeKey, terminalTheme])
@@ -253,6 +273,16 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
useImperativeHandle(
ref,
() => ({
prepareForForegroundRecovery() {
if (Platform.OS !== 'ios') {
return
}
// Why: direct ping is the only command allowed through while readiness is
// invalid; init/write commands queue until this exact document answers.
isWebReadyRef.current = false
armWebReadyWatchdog()
pendingPingIdRef.current = sendToWebView({ type: 'ping' })
},
write(data: string) {
postMessage({ type: 'write', data })
},
@@ -363,7 +393,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
})
}
}),
[postMessage, sendToWebView, terminalTheme, textScale]
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale]
)
return (
@@ -389,9 +419,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onRenderProcessGone={(event) =>
reportNativeEngineError('Terminal WebView render process ended', event)
}
onContentProcessDidTerminate={(event) =>
reportNativeEngineError('Terminal WebView content process ended', event)
}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
/>
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
@@ -127,5 +127,11 @@ describe('terminal foreground recovery', () => {
expect(foregroundPredicate).toContain('Platform.OS')
expect(sessionSource).toContain('recoverActiveTerminalAfterForeground({')
expect(sessionSource).toContain("AppState.addEventListener('change'")
const readinessInvalidation = sessionSource.indexOf(
'terminalRef.prepareForForegroundRecovery()'
)
const replay = sessionSource.indexOf('recoverActiveTerminalAfterForeground({')
expect(readinessInvalidation).toBeGreaterThanOrEqual(0)
expect(replay).toBeGreaterThan(readinessInvalidation)
})
})
@@ -52,6 +52,36 @@ describe('terminal viewport refit', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
expect(sessionSource).toContain('textScale: terminalTextScale')
expect(sessionSource).toContain('connState,')
})
it('forces a refit on iOS foreground and connection recovery', () => {
const foregroundEffect = hookSource.slice(
hookSource.indexOf("if (Platform.OS !== 'ios')"),
hookSource.indexOf('const previousConnStateRef')
)
const reconnectEffect = hookSource.slice(
hookSource.indexOf('const previousConnStateRef'),
hookSource.indexOf('disposedRef.current = false')
)
expect(foregroundEffect).toContain("AppState.addEventListener('change'")
expect(foregroundEffect).toContain('viewportMeasuredRef.current = false')
expect(foregroundEffect).toContain('scheduleForcedViewportRefit()')
expect(reconnectEffect).toContain("connState !== 'connected'")
expect(reconnectEffect).toContain('viewportMeasuredRef.current = false')
expect(reconnectEffect).toContain('scheduleForcedViewportRefit()')
})
it('bypasses the equal-dimensions guard for resume and reconnect refits', () => {
expect(hookSource).toContain('const forceRefit = forceNextRefitRef.current')
expect(hookSource).toContain(
'if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows)'
)
const forceRead = hookSource.indexOf('const forceRefit = forceNextRefitRef.current')
const updateViewport = hookSource.indexOf("sendRequest('terminal.updateViewport'")
expect(forceRead).toBeGreaterThanOrEqual(0)
expect(updateViewport).toBeGreaterThan(forceRead)
})
it('prefers the in-place updateViewport RPC over resubscribe', () => {
+49 -2
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { useWindowDimensions } from 'react-native'
import { AppState, Platform, useWindowDimensions, type AppStateStatus } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { TerminalWebViewHandle } from './TerminalWebView'
import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery'
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
@@ -19,6 +21,7 @@ type TerminalViewportRefitOptions = {
clientRef: RefObject<RpcClient | null>
deviceTokenRef: RefObject<string | null>
initializedHandlesRef: RefObject<Set<string>>
connState: ConnectionState
tabStripVisible: boolean
// Why: terminal text size (font scale) — changing it changes the cell size, so
// the PTY must be re-fitted to a new column count and reflowed.
@@ -49,6 +52,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
clientRef,
deviceTokenRef,
initializedHandlesRef,
connState,
tabStripVisible,
textScale,
terminalFrameWidth,
@@ -58,6 +62,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
const refitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const refitRunSeqRef = useRef(0)
const forceNextRefitRef = useRef(false)
const disposedRef = useRef(false)
const scheduleViewportRefit = useCallback(() => {
if (refitTimerRef.current) {
@@ -93,8 +98,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
if (!dims) {
return
}
const forceRefit = forceNextRefitRef.current
forceNextRefitRef.current = false
const prev = viewportRef.current
if (prev && prev.cols === dims.cols && prev.rows === dims.rows) {
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
return
}
viewportRef.current = dims
@@ -152,6 +159,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
unsubscribeTerminal,
subscribeToTerminal
])
const scheduleForcedViewportRefit = useCallback(() => {
forceNextRefitRef.current = true
scheduleViewportRefit()
}, [scheduleViewportRefit])
// Why: the tab strip is hidden when only one terminal exists and shown
// once a second is created. Crossing the 1↔2 boundary changes the
@@ -215,6 +226,42 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
scheduleViewportRefit()
}, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit])
useEffect(() => {
if (Platform.OS !== 'ios') {
return
}
let previousAppState: AppStateStatus | null = AppState.currentState
const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
const shouldRefit = shouldRecoverTerminalOnAppStateChange(
previousAppState,
nextAppState,
Platform.OS
)
previousAppState = nextAppState
if (!shouldRefit) {
return
}
// Why: the cached grid can match while the host PTY changed in background;
// reasserting equal dimensions is the convergence signal after iOS resume.
viewportMeasuredRef.current = false
scheduleForcedViewportRefit()
})
return () => sub.remove()
}, [viewportMeasuredRef, scheduleForcedViewportRefit])
const previousConnStateRef = useRef(connState)
useEffect(() => {
const previous = previousConnStateRef.current
previousConnStateRef.current = connState
if (previous === 'connected' || connState !== 'connected') {
return
}
// Why: reconnect can restore a PTY whose host-side size changed while the
// socket was down, so equal cached dimensions still need reassertion.
viewportMeasuredRef.current = false
scheduleForcedViewportRefit()
}, [connState, viewportMeasuredRef, scheduleForcedViewportRefit])
useEffect(() => {
disposedRef.current = false
return () => {
@@ -1,4 +1,5 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { StyleProp, ViewStyle } from 'react-native'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
@@ -37,7 +38,20 @@ export type TerminalSelectionEvents = {
onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewProps = {
style?: StyleProp<ViewStyle>
terminalTheme?: MobileTerminalTheme
// Why: baseline zoom multiplier applied on top of fit-to-width scale; raw
// xterm fontSize alone cannot drive apparent size because fitting cancels it.
textScale?: number
onWebReady?: () => void
onEngineError?: (message: string) => void
} & TerminalSelectionEvents
export type TerminalWebViewHandle = {
// Why: iOS can preserve the native view while discarding its JS/backing-store
// state; foreground recovery must wait for the document to answer before replay.
prepareForForegroundRecovery: () => void
write: (data: string) => void
init: (
cols: number,
@@ -1,10 +1,18 @@
import { createElement } from 'react'
import { createElement, createRef } from 'react'
import { Platform } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalWebView } from './TerminalWebView'
import type { TerminalWebViewHandle } from './terminal-webview-contract'
const nativeWebViewMethods = vi.hoisted(() => ({
postMessage: vi.fn<(message: string) => void>(),
reload: vi.fn<() => void>()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active' },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: {
absoluteFillObject: {
@@ -20,10 +28,14 @@ vi.mock('react-native', () => ({
View: 'View'
}))
vi.mock('react-native-webview', () => ({
WebView: 'WebView',
default: 'WebView'
}))
vi.mock('react-native-webview', async () => {
const React = await import('react')
const WebView = React.forwardRef((props: Record<string, unknown>, ref) => {
React.useImperativeHandle(ref, () => nativeWebViewMethods)
return React.createElement('WebView', props)
})
return { WebView, default: WebView }
})
vi.mock('lucide-react-native', () => ({
RefreshCw: 'RefreshCw'
@@ -45,12 +57,15 @@ function suppressReactTestRendererDeprecationWarning(): () => void {
// unmount so the timer can't survive into later tests and fire in teardown.
let activeRenderer: ReactTestRenderer | null = null
function createTerminalWebViewRenderer(onEngineError = vi.fn()) {
function createTerminalWebViewRenderer(
onEngineError = vi.fn(),
props: Record<string, unknown> = {}
) {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(TerminalWebView, { onEngineError }))
renderer = create(createElement(TerminalWebView, { onEngineError, ...props }))
})
} finally {
restoreConsoleError()
@@ -76,6 +91,10 @@ function renderedText(renderer: ReactTestRenderer): string {
.join(' ')
}
function postedCommands(): Array<Record<string, unknown>> {
return nativeWebViewMethods.postMessage.mock.calls.map(([message]) => JSON.parse(message))
}
describe('TerminalWebView engine errors', () => {
afterEach(() => {
if (activeRenderer) {
@@ -84,6 +103,7 @@ describe('TerminalWebView engine errors', () => {
})
activeRenderer = null
}
vi.clearAllMocks()
vi.restoreAllMocks()
})
@@ -175,4 +195,78 @@ describe('TerminalWebView engine errors', () => {
vi.useRealTimers()
}
})
it('queues iOS foreground traffic until the current document answers its ping', () => {
const terminalRef = createRef<TerminalWebViewHandle>()
const onWebReady = vi.fn()
const terminalTheme = {
mode: 'dark',
theme: { background: '#111111', foreground: '#eeeeee' }
}
const { renderer } = createTerminalWebViewRenderer(vi.fn(), {
ref: terminalRef,
onWebReady,
terminalTheme
})
postWebViewMessage(renderer, { type: 'web-ready' })
nativeWebViewMethods.postMessage.mockClear()
act(() => {
terminalRef.current?.prepareForForegroundRecovery()
terminalRef.current?.write('queued output')
})
const ping = postedCommands()[0]
expect(postedCommands().map((command) => command.type)).toEqual(['ping'])
postWebViewMessage(renderer, { type: 'pong', pingId: Number(ping?.id) + 1 })
expect(postedCommands().map((command) => command.type)).toEqual(['ping'])
postWebViewMessage(renderer, { type: 'pong', pingId: ping?.id })
expect(postedCommands().map((command) => command.type)).toEqual(['ping', 'set-theme', 'write'])
expect(onWebReady).toHaveBeenCalledTimes(1)
})
it('keeps Android foreground traffic on the existing ready document', () => {
const terminalRef = createRef<TerminalWebViewHandle>()
const { renderer } = createTerminalWebViewRenderer(vi.fn(), { ref: terminalRef })
postWebViewMessage(renderer, { type: 'web-ready' })
nativeWebViewMethods.postMessage.mockClear()
const mutablePlatform = Platform as { OS: string }
mutablePlatform.OS = 'android'
try {
act(() => {
terminalRef.current?.prepareForForegroundRecovery()
terminalRef.current?.write('android output')
})
expect(postedCommands().map((command) => command.type)).toEqual(['write'])
} finally {
mutablePlatform.OS = 'ios'
}
})
it('reloads a terminated iOS content process and restores theme on readiness', () => {
const terminalRef = createRef<TerminalWebViewHandle>()
const { onEngineError, renderer } = createTerminalWebViewRenderer(vi.fn(), {
ref: terminalRef,
terminalTheme: {
mode: 'light',
theme: { background: '#ffffff', foreground: '#111111' }
}
})
postWebViewMessage(renderer, { type: 'web-ready' })
nativeWebViewMethods.postMessage.mockClear()
const webView = renderer.root.findByType('WebView')
act(() => {
webView.props.onContentProcessDidTerminate({ nativeEvent: {} })
terminalRef.current?.write('after termination')
})
expect(nativeWebViewMethods.reload).toHaveBeenCalledTimes(1)
expect(nativeWebViewMethods.postMessage).not.toHaveBeenCalled()
expect(onEngineError).not.toHaveBeenCalled()
postWebViewMessage(renderer, { type: 'web-ready' })
expect(postedCommands().map((command) => command.type)).toEqual(['set-theme', 'write'])
})
})
@@ -1,15 +1,95 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
function createWebglRecoveryHarness(failSecondAttach = false) {
const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;')
const variablesEnd = terminalHtmlSource.indexOf(
'\n',
terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;')
)
expect(variablesStart).toBeGreaterThanOrEqual(0)
expect(variablesEnd).toBeGreaterThan(variablesStart)
const timers: Array<() => void> = []
const addons: Array<{
clearTextureAtlas: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
fireContextLoss: () => void
}> = []
const term = {
rows: 24,
refresh: vi.fn(),
loadAddon: vi.fn(() => {
if (failSecondAttach && addons.length === 2) {
throw new Error('retry unavailable')
}
})
}
function WebglAddon() {
let contextLoss = () => {}
const addon = {
clearTextureAtlas: vi.fn(),
dispose: vi.fn(),
fireContextLoss: () => contextLoss()
}
addons.push(addon)
return Object.assign(addon, {
onContextLoss: (listener: () => void) => {
contextLoss = listener
}
})
}
let visibilityChange = () => {}
const document = {
addEventListener: vi.fn((eventName: string, listener: () => void) => {
if (eventName === 'visibilitychange') {
visibilityChange = listener
}
}),
visibilityState: 'hidden'
}
const applyTerminalTheme = vi.fn()
const flog = vi.fn()
const terminalThemeInput = { mode: 'dark' }
const context = {
applyTerminalTheme,
clearTimeout: vi.fn(),
document,
flog,
setTimeout: (callback: () => void) => {
timers.push(callback)
return timers.length
},
term,
terminalGeneration: 1,
terminalThemeInput,
window: { WebglAddon: { WebglAddon } }
}
new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)}
${TERMINAL_WEBGL_RECOVERY_JS}
attachWebglAddon(true);`).runInNewContext(context)
return {
addons,
applyTerminalTheme,
document,
fireVisibilityChange: () => visibilityChange(),
flog,
term,
terminalThemeInput,
timers
}
}
describe('terminal WebView bundled engine', () => {
it('keeps the assembled terminal HTML free of external engine URLs', () => {
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
@@ -106,4 +186,57 @@ describe('terminal WebView bundled engine', () => {
expect(capSites.length).toBe(2)
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
})
it('recreates WebGL once after context loss, then stays on the DOM renderer', () => {
const { addons, flog, term, timers } = createWebglRecoveryHarness()
expect(addons).toHaveLength(1)
addons[0]?.fireContextLoss()
expect(flog).toHaveBeenCalledWith(
'webgl-context-loss',
expect.objectContaining({ retry: true })
)
expect(addons[0]?.dispose).toHaveBeenCalledTimes(1)
expect(term.refresh).toHaveBeenCalledTimes(1)
expect(timers).toHaveLength(1)
timers.shift()?.()
expect(addons).toHaveLength(2)
expect(addons[1]?.clearTextureAtlas).toHaveBeenCalledTimes(1)
expect(term.refresh).toHaveBeenCalledTimes(2)
addons[1]?.fireContextLoss()
expect(addons[1]?.dispose).toHaveBeenCalledTimes(1)
expect(term.refresh).toHaveBeenCalledTimes(3)
expect(timers).toHaveLength(0)
})
it('falls back to a refreshed DOM renderer when the delayed WebGL retry fails', () => {
const { addons, term, timers } = createWebglRecoveryHarness(true)
addons[0]?.fireContextLoss()
timers.shift()?.()
expect(addons).toHaveLength(2)
expect(addons[1]?.dispose).toHaveBeenCalledTimes(1)
expect(term.refresh).toHaveBeenCalledTimes(2)
})
it('reapplies theme, clears the active atlas, and refreshes when visible', () => {
const harness = createWebglRecoveryHarness()
harness.fireVisibilityChange()
expect(harness.applyTerminalTheme).not.toHaveBeenCalled()
harness.document.visibilityState = 'visible'
harness.fireVisibilityChange()
expect(harness.applyTerminalTheme).toHaveBeenCalledWith(harness.terminalThemeInput)
expect(harness.addons[0]?.clearTextureAtlas).toHaveBeenCalledTimes(1)
expect(harness.term.refresh).toHaveBeenCalledTimes(1)
})
it('answers native readiness probes from the live document', () => {
expect(terminalHtmlSource).toContain("if (msg.type === 'ping')")
expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })")
})
})
+14 -25
View File
@@ -7,7 +7,9 @@ import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
background: colors.terminalBg,
@@ -292,7 +294,10 @@ window.onerror = function(msg) {
var initRows = 24;
var terminalGeneration = 0;
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
var webglAddon = null;
var webglRecoveryTimer = null;
var activeAltScreenSnapshot = false;
var trackedMouseTrackingMode = 'none';
var sgrMouseMode = false;
@@ -380,27 +385,7 @@ window.onerror = function(msg) {
}, 550);
}
function normalizeTerminalTheme(input) {
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
: null;
if (!source) return defaultTheme;
var next = {};
var keys = Object.keys(defaultTheme);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
if (typeof source[key] === 'string') next[key] = source[key];
}
return Object.assign({}, defaultTheme, next);
}
function applyTerminalTheme(input) {
terminalTheme = normalizeTerminalTheme(input);
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
if (term) term.options.theme = terminalTheme;
}
${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
@@ -679,6 +664,8 @@ window.onerror = function(msg) {
pumpWrites(terminalGeneration);
}
${TERMINAL_WEBGL_RECOVERY_JS}
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
// Why: a width-reflow re-stream rewraps the same content at new cols.
@@ -688,6 +675,8 @@ window.onerror = function(msg) {
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
terminalGeneration++;
var gen = terminalGeneration;
cancelWebglContextRecovery();
webglAddon = null;
ready = false;
resetWriteQueue();
statusDotPendingSelector = false;
@@ -750,9 +739,7 @@ window.onerror = function(msg) {
allowProposedApi: true
});
term.open(surface);
if (window.WebglAddon && window.WebglAddon.WebglAddon) {
try { var webglAddon = new window.WebglAddon.WebglAddon(); term.loadAddon(webglAddon); if (webglAddon.onContextLoss) webglAddon.onContextLoss(function() { try { webglAddon && webglAddon.dispose && webglAddon.dispose(); } catch (e) {} }); } catch (e) {}
}
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
if (typeof replayData === 'string' && replayData.length > 0) {
enqueueWrite(replayData);
@@ -936,7 +923,9 @@ window.onerror = function(msg) {
handledMessageIds.push(msg.id);
if (handledMessageIds.length > 256) handledMessageIds.shift();
}
if (msg.type === 'init') {
if (msg.type === 'ping') {
notify({ type: 'pong', pingId: msg.id });
} else if (msg.type === 'init') {
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks);
} else if (msg.type === 'set-font-scale') {
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
@@ -2,6 +2,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
export type TerminalWebViewCommand =
| { type: 'ping'; id?: number }
| { type: 'write'; id?: number; data: string }
| {
type: 'init'
@@ -10,6 +10,10 @@ const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
const terminalWebglRecoverySource = readFileSync(
new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url),
'utf8'
)
function extractStatusDotNormalizer() {
const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =')
@@ -148,12 +152,12 @@ describe('TerminalWebView text zoom', () => {
it('uses the bundled WebGL-capable xterm stack and platform-safe font fallbacks', () => {
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
expect(terminalHtmlSource).toContain('window.WebglAddon.WebglAddon')
expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon')
expect(terminalHtmlSource).toContain('function isIOSWebView()')
expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily')
expect(terminalHtmlSource).toContain("fontWeight: '300'")
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
expect(terminalHtmlSource).toContain('new window.WebglAddon.WebglAddon()')
expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()')
})
const IOS_IPHONE_NAVIGATOR = {
@@ -0,0 +1,27 @@
import { colors } from '../theme/mobile-theme'
// Theme normalization and page-surface painting injected into the WebView IIFE.
export const TERMINAL_WEBVIEW_THEME_JS = `
function normalizeTerminalTheme(input) {
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
: null;
if (!source) return defaultTheme;
var next = {};
var keys = Object.keys(defaultTheme);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
if (typeof source[key] === 'string') next[key] = source[key];
}
return Object.assign({}, defaultTheme, next);
}
function applyTerminalTheme(input) {
terminalThemeInput = input;
terminalTheme = normalizeTerminalTheme(input);
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
if (term) term.options.theme = terminalTheme;
}
`
@@ -0,0 +1,62 @@
// WebGL loss and visibility recovery injected into the terminal WebView IIFE.
// It closes over term, terminalGeneration, theme state, and xterm's addon global.
export const TERMINAL_WEBGL_RECOVERY_JS = `
function refreshTerminalSurface() {
if (!term) return;
try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {}
}
function cancelWebglContextRecovery() {
if (!webglRecoveryTimer) return;
clearTimeout(webglRecoveryTimer);
webglRecoveryTimer = null;
}
function attachWebglAddon(allowRecovery) {
if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false;
var addon = null;
try {
addon = new window.WebglAddon.WebglAddon();
webglAddon = addon;
if (addon.onContextLoss) addon.onContextLoss(function() {
if (webglAddon !== addon) return;
flog('webgl-context-loss', { retry: allowRecovery });
webglAddon = null;
try { addon.dispose(); } catch (e) {}
refreshTerminalSurface();
if (!allowRecovery) return;
// Why: one delayed retry handles transient iOS context loss without
// entering a GPU crash loop; a second loss stays on the DOM renderer.
cancelWebglContextRecovery();
var recoveryTerm = term;
var recoveryGeneration = terminalGeneration;
webglRecoveryTimer = setTimeout(function() {
webglRecoveryTimer = null;
if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return;
attachWebglAddon(false);
}, 100);
});
term.loadAddon(addon);
if (!allowRecovery) {
try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {}
refreshTerminalSurface();
}
return true;
} catch (e) {
flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) });
if (webglAddon === addon) webglAddon = null;
try { if (addon) addon.dispose(); } catch (disposeError) {}
refreshTerminalSurface();
return false;
}
}
document.addEventListener('visibilitychange', function() {
if (document.visibilityState !== 'visible') return;
// Why: iOS may restore the xterm model while discarding GPU pixels/theme
// paint state, so visibility must rebuild the atlas and repaint every row.
applyTerminalTheme(terminalThemeInput);
try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {}
refreshTerminalSurface();
});
`