refactor(mobile): make WebGL loss recovery a module

The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.

The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.

Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.

The document is untouched, so the byte pin is still green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 08:58:03 -04:00
parent 4bfc1da79b
commit e8072835fc
4 changed files with 154 additions and 1 deletions
@@ -32,3 +32,9 @@ export declare function applyFitScale(reason: string): void
/** `smooth-scroll-and-cell-geometry`: shows or hides the scroll indicator. */
export declare function updateScrollIndicator(visible: boolean): void
/** `runtime-state-and-text-scaling`: one diagnostic line, forwarded to the host. */
export declare function flog(name: string, detail: Record<string, unknown>): void
/** `terminal-fit-scale`: applies a theme the host sent to the live terminal. */
export declare function applyTerminalTheme(input: unknown): void
+19 -1
View File
@@ -53,6 +53,8 @@ export type TerminalDocumentTerminal = {
readonly rows: number
readonly buffer: { readonly active: TerminalDocumentBuffer }
resize: (cols: number, rows: number) => void
refresh: (start: number, end: number) => void
loadAddon: (addon: TerminalDocumentWebglAddon) => void
scrollToBottom: () => void
scrollLines: (amount: number) => void
}
@@ -69,11 +71,24 @@ export type TerminalDocumentScope = {
termObserverDisposables: TerminalDocumentDisposable[]
/** `terminal-init-and-write`: the row count the last init or reflow settled on. */
initRows: number
/** `webgl-recovery`: the loaded WebGL addon, or null on the DOM renderer. */
webglAddon: TerminalDocumentWebglAddon | null
/** `webgl-recovery`: the pending single retry after a context loss. */
webglRecoveryTimer: ReturnType<typeof setTimeout> | null
/** `runtime-state-and-text-scaling`: the theme the host last sent, replayed on visibility. */
terminalThemeInput: unknown
}
/** An xterm listener handle, as the document disposes of one. */
export type TerminalDocumentDisposable = { dispose?: () => void }
/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */
export type TerminalDocumentWebglAddon = {
onContextLoss?: (listener: () => void) => void
clearTextureAtlas?: () => void
dispose: () => void
}
/**
* The initial values, which are the ones the document's own declarations carried.
*
@@ -87,7 +102,10 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
panY: 0,
terminalGeneration: 0,
termObserverDisposables: [],
initRows: 24
initRows: 24,
webglAddon: null,
webglRecoveryTimer: null,
terminalThemeInput: null
}
}
@@ -0,0 +1,24 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected'
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
const modulePath = fileURLToPath(new URL('./webgl-recovery.ts', import.meta.url))
describe('the WebGL recovery module', () => {
it('emits the script the document carries, modulo the four normalisations', async () => {
const emitted = await emitTerminalDocumentModule(modulePath)
expect(compareTerminalDocumentScripts(TERMINAL_WEBGL_RECOVERY_JS, emitted, 'scope')).toEqual({
equivalent: true,
normalisations: {
qualifiedReferences: 23,
scopeFieldDeclarations: 0,
rebindings: 3,
bracedBodies: 12,
// Five of the six catch clauses; the attach failure reads its error and keeps its binding.
unboundCatches: 5
}
})
})
})
@@ -0,0 +1,105 @@
import { applyTerminalTheme, flog } from './document-externals'
import { scope, type TerminalDocumentWebglAddon } from './document-scope'
/** xterm's WebGL addon constructor, as the engine bundle puts it on `window`. */
type WebglAddonGlobal = { WebglAddon?: new () => TerminalDocumentWebglAddon }
declare global {
interface Window {
WebglAddon?: WebglAddonGlobal
}
}
export function refreshTerminalSurface() {
if (!scope.term) {
return
}
try {
scope.term.refresh(0, Math.max(0, scope.term.rows - 1))
} catch {}
}
export function cancelWebglContextRecovery() {
if (!scope.webglRecoveryTimer) {
return
}
clearTimeout(scope.webglRecoveryTimer)
scope.webglRecoveryTimer = null
}
export function attachWebglAddon(allowRecovery: boolean) {
if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {
return false
}
let addon: TerminalDocumentWebglAddon | null = null
try {
addon = new window.WebglAddon.WebglAddon()
scope.webglAddon = addon
if (addon.onContextLoss) {
addon.onContextLoss(function () {
if (scope.webglAddon !== addon) {
return
}
flog('webgl-context-loss', { retry: allowRecovery })
scope.webglAddon = null
try {
// oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: this listener is registered on the addon, so it cannot run before the assignment above.
addon!.dispose()
} catch {}
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()
const recoveryTerm = scope.term
const recoveryGeneration = scope.terminalGeneration
scope.webglRecoveryTimer = setTimeout(function () {
scope.webglRecoveryTimer = null
if (scope.term !== recoveryTerm || scope.terminalGeneration !== recoveryGeneration) {
return
}
attachWebglAddon(false)
}, 100)
})
}
scope.term.loadAddon(addon)
if (!allowRecovery) {
try {
if (addon.clearTextureAtlas) {
addon.clearTextureAtlas()
}
} catch {}
refreshTerminalSurface()
}
return true
} catch (e) {
flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) })
if (scope.webglAddon === addon) {
scope.webglAddon = null
}
try {
if (addon) {
addon.dispose()
}
} catch {}
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(scope.terminalThemeInput)
try {
if (scope.webglAddon && scope.webglAddon.clearTextureAtlas) {
scope.webglAddon.clearTextureAtlas()
}
} catch {}
refreshTerminalSurface()
})