refactor(mobile): extract the host-message-router slice into modules

Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.

The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.

Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 09:54:21 -04:00
parent b64ec213df
commit 8094eebda9
16 changed files with 338 additions and 36 deletions
@@ -9,9 +9,6 @@
* This file only shrinks. When the last group moves it is deleted.
*/
/** `runtime-state-and-text-scaling`: posts one message to the native host. */
export declare function notify(message: Record<string, unknown>): void
/** `surface-touch-gestures`: binds touch, wheel and tap handlers to a surface element. */
export declare function attachSurfaceEventHandlers(target: HTMLElement): void
@@ -32,9 +29,3 @@ export declare function applyXtermSelection(): void
/** `selection-overlay`: moves the handles and the menu pill to the current selection. */
export declare function repositionOverlay(): void
/** `host-message-router`: routes one decoded host message. */
export declare function handleMsg(msg: unknown): void
/** `terminal-init-and-write`: reports an engine failure to the host. */
export declare function reportEngineError(summary: string, cause: unknown, fatal: unknown): void
@@ -30,7 +30,7 @@ export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: str
/** The xterm internals the OSC 8 lookup walks. */
export type TerminalDocumentCore = {
_renderService?: { dimensions?: { css: { cell: { height?: number; width?: number } } } }
_renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } }
_oscLinkService?: TerminalOscLinkService
_inputHandler?: { _oscLinkService?: TerminalOscLinkService }
}
@@ -62,6 +62,7 @@ export type TerminalDocumentLine = {
/** One side of xterm's buffer, as the document reads it. */
export type TerminalDocumentBuffer = {
readonly length: number
readonly viewportY: number
readonly baseY: number
readonly cursorY: number
@@ -89,6 +90,9 @@ export type TerminalDocumentTerminal = {
write: (data: string, callback?: () => void) => void
open: (element: HTMLElement) => void
scrollToLine: (line: number) => void
clear: () => void
reset: () => void
selectAll: () => void
readonly unicode: { activeVersion: string }
attachCustomKeyEventHandler: (handler: () => boolean) => void
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
@@ -170,7 +174,7 @@ export type TerminalDocumentScope = {
/** `text-scaling`: the largest text-scale preset. */
MAX_TEXT_SCALE: number
/** `viewport-transform`: host message ids already handled, to drop repeats. */
handledMessageIds: string[]
handledMessageIds: number[]
/** `runtime-state`: the text scale the user picked, as a preset index. */
currentTextScale: number
/** `runtime-state`: the font stack xterm renders with. */
@@ -0,0 +1,30 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from '../terminal-webview-html/host-message-router'
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
/** The slice opens with the already-extracted reflow group. */
const modulePaths = ['./reflow.ts', './host-notify.ts', './host-message-router.ts'].map(
(relative) => fileURLToPath(new URL(relative, import.meta.url))
)
describe('the host-message-router slice', () => {
it('emits the script the document carries, modulo the six normalisations', async () => {
const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n')
expect(
compareTerminalDocumentScripts(TERMINAL_HTML_HOST_MESSAGE_ROUTER, emitted, 'scope')
).toEqual({
equivalent: true,
normalisations: {
qualifiedReferences: 48,
scopeFieldDeclarations: 0,
rebindings: 20,
bracedBodies: 12,
unboundCatches: 2,
numberProperties: 0,
shorthandProperties: 0
}
})
})
})
@@ -0,0 +1,195 @@
import { scope } from './document-scope'
import { applyFitScale } from './fit-scale'
import { notify } from './host-notify'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
import { emitModesIfChanged } from './mode-mirroring'
import { reflow } from './reflow'
import { resumeTerminalDataReplyAuthority } from './query-reply'
import { cancelSelect, repositionOverlay } from './document-externals'
import { resetEvictionCounter } from './selection-state-and-eviction'
import { applyTerminalTheme } from './terminal-theme'
import { init, resize, write } from './terminal-init'
import { applyTextScale } from './text-scaling'
import { flog } from './viewport-transform'
import { resetWriteQueue } from './write-queue'
/** One message from the host. Every field is optional because the router reads them by type. */
export type TerminalHostMessage = {
id?: number
type?: string
cols?: number
rows?: number
initialData?: unknown
terminalTheme?: Parameters<typeof applyTerminalTheme>[0]
fontScale?: number
preserveScroll?: boolean
oscLinks?: unknown
data?: string
containerHeight?: number
}
export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: number) {
if (typeof retriesLeft !== 'number') {
retriesLeft = 30
}
// Why: init and measure are posted back-to-back from React, but
// init has an async rAF chain. A measure that runs synchronously
// after init can find term null, disposed, lacking element, or
// with cells size 0. Retry the whole gate for ~500ms.
const notReady = !scope.term || !scope.term.element
let cellWidth = 0
let cellHeight = 0
if (!notReady) {
// oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: notReady is exactly the absence check above.
const core = scope.term!._core
if (core && core._renderService && core._renderService.dimensions) {
cellWidth = core._renderService.dimensions.css.cell.width
cellHeight = core._renderService.dimensions.css.cell.height
}
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function () {
measureFitDimensions(containerHeightPx, retriesLeft - 1)
})
return
}
flog('measure-fail', {
notReady: notReady,
cellWidth: cellWidth,
cellHeight: cellHeight,
retriesLeft: retriesLeft
})
notify({ type: 'measure-result', cols: null, rows: null })
return
}
const vpWidth = window.innerWidth
// Why: prefer the container height passed from React Native over
// window.innerHeight. The RN layout system knows the exact pixel
// height of the terminal frame after the accessory/input bars are
// subtracted, whereas innerHeight can overstate the visible area
// due to layout timing or safe-area insets.
const vpHeight =
typeof containerHeightPx === 'number' && containerHeightPx > 0
? containerHeightPx
: window.innerHeight
const cols = Math.floor(vpWidth / cellWidth)
if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width', {
vpWidth: vpWidth,
cellWidth: cellWidth,
cols: cols
})
notify({ type: 'measure-result', cols: null, rows: null })
return
}
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Subtracting rows here would leave
// dead xterm-background space at the bottom of the container and make
// the last PTY rows visually appear above an "invisible line." Any
// safety margin between the prompt and the accessory bar must come
// from RN layout (terminalFrame's flex bounds), not from undersizing
// the PTY.
const rows = Math.max(8, Math.floor(vpHeight / cellHeight))
notify({ type: 'measure-result', cols: cols, rows: rows })
}
export function handleMsg(msg: TerminalHostMessage) {
if (typeof msg.id === 'number') {
// oxlint-disable-next-line unicorn/prefer-includes -- the document's text is pinned token for token; rewriting this changes the native program
if (scope.handledMessageIds.indexOf(msg.id) !== -1) {
return
}
scope.handledMessageIds.push(msg.id)
if (scope.handledMessageIds.length > 256) {
scope.handledMessageIds.shift()
}
}
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 ===
// currentTextScale) so the post-pinch state isn't reset; only apply changes.
if (
typeof msg.fontScale === 'number' &&
msg.fontScale > 0 &&
msg.fontScale !== scope.currentTextScale
) {
scope.userScale = 1
scope.panX = 0
scope.panY = 0
applyTextScale(msg.fontScale)
}
} else if (msg.type === 'resize') {
resize(msg.cols!, msg.rows!)
} else if (msg.type === 'reflow') {
reflow(msg.cols!, msg.rows!)
} else if (msg.type === 'write') {
write(msg.data!)
} else if (msg.type === 'clear') {
scope.terminalGeneration++
resetWriteQueue()
resumeTerminalDataReplyAuthority() // Why: clear drops the replay boundary.
scope.statusDotPendingSelector = false
scope.afterDrainCallbacks = []
scope.writesDraining = false
scope.mouseModeScanTail = ''
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
scope.initialOscLinks = []
scope.initialOscLinkRowOffset = 0
scope.initialOscLinkEvictionReady = false
if (scope.term) {
scope.term.clear()
scope.term.reset()
}
emitModesIfChanged()
emitKeyboardAvoidanceMetrics()
resetEvictionCounter()
if (scope.selMode === 'select') {
notify({ type: 'selection-evicted' })
cancelSelect()
}
} else if (msg.type === 'measure') {
measureFitDimensions(msg.containerHeight)
} else if (msg.type === 'reset-zoom') {
applyFitScale('reset-zoom-msg')
} else if (msg.type === 'set-theme') {
applyTerminalTheme(msg.terminalTheme)
} else if (msg.type === 'cancel-select') {
if (scope.selMode === 'select') {
cancelSelect()
}
} else if (msg.type === 'do-select-all') {
if (scope.term) {
try {
scope.term.selectAll()
const b = scope.term.buffer.active
if (scope.selMode !== 'select') {
scope.selMode = 'select'
// oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: the document shell ships the overlay element.
scope.selectionOverlay!.classList.add('active')
notify({ type: 'set-select-mode', enabled: true })
}
scope.sel = {
anchor: { col: 0, row: 0 },
focus: { col: scope.term.cols - 1, row: b.length - 1 },
activeHandle: null
}
repositionOverlay()
} catch {}
}
}
}
@@ -0,0 +1,86 @@
import { scope } from './document-scope'
/**
* The postMessage bridge to the host, and the engine error reporting that rides on it.
*
* They are one module because the document declares them together, ahead of the message router
* that both serve.
*/
declare global {
interface Window {
__engineErrors: string[]
}
}
export function notify(msg: Record<string, unknown>) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg))
}
}
/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */
export type TerminalEngineError = string | null | undefined | { message?: unknown }
export function engineErrorText(err: TerminalEngineError) {
if (!err) {
return ''
}
if (typeof err === 'string') {
return err
}
if (err && typeof err.message === 'string') {
return err.message
}
try {
return String(err)
} catch {
return ''
}
}
export function chromeVersionText() {
const match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/)
return match ? 'Chrome ' + match[1] : 'Chrome version unknown'
}
let nonFatalErrorNotifies = 0
export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) {
const isFatal = fatal === undefined ? !scope.everReady : !!fatal
if (!isFatal) {
// Why: a constructed-but-degraded engine can throw per frame; cap
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
nonFatalErrorNotifies++
if (nonFatalErrorNotifies > 5) {
return
}
}
const parts = [context]
const errText = engineErrorText(err)
if (errText) {
parts.push(errText)
}
if (window.__engineErrors && window.__engineErrors.length) {
parts.push('captured: ' + window.__engineErrors.join(' | '))
}
parts.push(chromeVersionText())
notify({
type: 'error',
fatal: isFatal,
message: parts.join(' - ')
})
}
window.onerror = function (
msg: string | (Event & { message?: unknown }),
source,
line,
column,
err?: TerminalEngineError
) {
if (window.__engineErrors.length < 20) {
window.__engineErrors.push(String(msg))
}
reportEngineError('terminal runtime error', err || msg)
}
@@ -1,4 +1,4 @@
import { notify } from './document-externals'
import { notify } from './host-notify'
import { scope, type TerminalDocumentCell, type TerminalDocumentLine } from './document-scope'
export function lineHasVisibleContent(
@@ -1,5 +1,7 @@
import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale'
import { handleMsg, notify, reportEngineError, repositionOverlay } from './document-externals'
import { repositionOverlay } from './document-externals'
import { handleMsg, type TerminalHostMessage } from './host-message-router'
import { notify, reportEngineError, type TerminalEngineError } from './host-notify'
import { updateTransform } from './viewport-transform'
import { scope } from './document-scope'
@@ -9,9 +11,6 @@ declare global {
}
}
/** The decoded host message; only its type is read here, by the error reporter. */
type TerminalHostMessage = { type?: unknown } | undefined
export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | string }) {
let msg: TerminalHostMessage
try {
@@ -20,11 +19,12 @@ export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage |
return
}
try {
handleMsg(msg)
handleMsg(msg!)
} catch (ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
ex,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a catch binding is `unknown`; the reporter reads only `message` and falls back to String().
ex as TerminalEngineError,
msg && msg.type === 'init' && !scope.everReady
)
}
@@ -1,4 +1,4 @@
import { notify } from './document-externals'
import { notify } from './host-notify'
import { getMouseTrackingMode } from './mouse-input-encoding'
import { scope } from './document-scope'
@@ -2,10 +2,10 @@ import {
applyXtermSelection,
cancelSelect,
handleDragMove,
notify,
repositionOverlay,
stopEdgeScroll
} from './document-externals'
import { notify } from './host-notify'
import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding'
import { viewportToCell } from './viewport-cell'
import { scope } from './document-scope'
@@ -1,4 +1,4 @@
import { notify } from './document-externals'
import { notify } from './host-notify'
import { scope } from './document-scope'
import { viewportToMouseReportCell } from './mouse-report-cell'
+1 -1
View File
@@ -1,5 +1,5 @@
import { enqueueWriteBoundary } from './write-queue'
import { notify } from './document-externals'
import { notify } from './host-notify'
import { scope, type TerminalDocumentDisposable } from './document-scope'
/**
@@ -1,4 +1,5 @@
import { cancelSelect, notify, repositionOverlay } from './document-externals'
import { cancelSelect, repositionOverlay } from './document-externals'
import { notify } from './host-notify'
import { scope } from './document-scope'
// ============================================================
+1 -1
View File
@@ -1,4 +1,4 @@
import { notify } from './document-externals'
import { notify } from './host-notify'
import {
buildMouseClickInput,
getMouseTrackingMode,
+2 -7
View File
@@ -1,10 +1,5 @@
import {
cancelSelect,
enterSelect,
handleDragMove,
notify,
stopEdgeScroll
} from './document-externals'
import { cancelSelect, enterSelect, handleDragMove, stopEdgeScroll } from './document-externals'
import { notify } from './host-notify'
import { viewportToCell } from './viewport-cell'
import { scope } from './document-scope'
import { notifyTerminalSurfaceTap } from './surface-tap'
@@ -5,7 +5,7 @@ import {
terminalCursorStyle,
terminalShowCursorImmediately
} from './document-constants'
import { notify } from './document-externals'
import { notify } from './host-notify'
import { fontPxForScale } from './text-scaling'
import {
scope,
@@ -8,10 +8,10 @@ export type TerminalDocumentRgb = { r: number; g: number; b: number }
export type TerminalDocumentRgba = TerminalDocumentRgb & { a: number }
/** The theme payload the host publishes; an older host omits the contrast floor. */
export type TerminalDocumentThemeMessage = {
theme?: Record<string, string>
minimumContrastRatio?: number
} | null
export type TerminalDocumentThemeMessage =
| { theme?: Record<string, string>; minimumContrastRatio?: number }
| null
| undefined
const DARK_BG_MIN_CONTRAST = 3
const LIGHT_BG_MIN_CONTRAST = 4.5