perf(mobile): defer unopened terminal document allocation (#34743)

* perf(mobile): defer terminal documents until first selection

* test(mobile): address review findings on pane-gating test

* test(mobile): use T[] array type in terminal document mount test

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-27 23:58:24 -07:00
committed by GitHub
co-authored by OrcaWin
parent f0e97a6b2d
commit 9d33005090
2 changed files with 120 additions and 1 deletions
+9 -1
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import { StyleSheet, View } from 'react-native'
import type { ParsedFileLinkLocation } from '../../../src/shared/file-link-location'
import { TerminalWebView } from '../terminal/TerminalWebView'
@@ -57,6 +57,10 @@ export function TerminalPaneView({
onTextScaleChange,
onCellBoxChange
}: TerminalPaneViewProps) {
const [visited, setVisited] = useState(active)
if (active && !visited) {
setVisited(true)
}
const setRef = useCallback(
(ref: TerminalWebViewHandle | null) => {
onRef(handle, ref)
@@ -64,6 +68,10 @@ export function TerminalPaneView({
[handle, onRef]
)
if (!active && !visited) {
return null
}
return (
<View
// Why: inactive terminal WebViews stay mounted to preserve xterm state,
@@ -5,6 +5,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { terminalDocumentDouble } from './document/document-terminal-double.test-support'
import type { TerminalDocumentTerminal } from './document/document-terminal-shape'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import type { TerminalWebViewHandle } from './terminal-webview-contract'
import { TerminalPaneView } from '../session/TerminalPaneView'
import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
import terminalNerdFontUrl from '../../assets/fonts/SymbolsNerdFontMono-Regular.woff2'
@@ -38,6 +40,7 @@ vi.mock('react-native', () => ({
AppState: { addEventListener: () => ({ remove() {} }) }
}))
vi.mock('lucide-react-native', () => ({ RefreshCw: 'Icon' }))
vi.mock('./TerminalWebView', () => import('./TerminalWebView.web'))
const { createTerminalDocument } = await import('./document/create-terminal-document')
const { mountTerminalWebDocument } = await import('./terminal-web-document-mount')
@@ -146,6 +149,114 @@ describe('a stopped document takes its engines with it', () => {
})
})
describe('terminal panes start when first selected', () => {
it('starts only the selected document among several terminal tabs', () => {
vi.useFakeTimers()
const count = 3
const refs = new Map<string, TerminalWebViewHandle>()
const ready: string[] = []
const engines: ReturnType<typeof terminalDocumentDouble>[] = []
const replayed: string[] = []
const addListener = vi.spyOn(document, 'addEventListener')
const removeListener = vi.spyOn(document, 'removeEventListener')
gridTerminal = () => {
const engine = terminalDocumentDouble()
engine.terminal.write = (data, callback) => {
replayed.push(data)
callback?.()
}
engines.push(engine)
return engine.terminal
}
const noop = () => {}
const onRef = (handle: string, ref: TerminalWebViewHandle | null) => {
if (ref) {
refs.set(handle, ref)
} else {
refs.delete(handle)
}
}
const onWebReady = (handle: string) => {
ready.push(handle)
const ref = refs.get(handle)
expect(ref).toBeDefined()
void ref?.init({ ...INIT, initialData: `snapshot:${handle}`, frame: null })
}
const panes = (selected: number) =>
Array.from({ length: count }, (_, index) =>
createElement(TerminalPaneView, {
key: index,
handle: `terminal-${index}`,
active: index === selected,
keyboardLift: 0,
textScale: 1,
onRef,
onWebReady,
onTextScaleChange: noop,
onCellBoxChange: noop,
onSelectionMode: noop,
onSelectionCopy: noop,
onSelectionEvicted: noop,
onClipboardWrite: noop,
onModesChanged: noop,
onKeyboardAvoidanceMetrics: noop,
onHaptic: noop,
onTerminalInput: noop,
onTerminalQueryReply: noop,
onTerminalTap: noop,
onFileTap: noop,
onOpenUrl: noop
})
)
const documentListeners = () => addListener.mock.calls.length - removeListener.mock.calls.length
try {
act(() => {
renderer = create(createElement('div', null, panes(0)), {
createNodeMock: () => plantHost()
})
})
expect(startedBuilds).toEqual([true])
expect(ready).toEqual(['terminal-0'])
expect(refs.size).toBe(1)
expect(document.querySelectorAll(`.${HOST_CLASS}`)).toHaveLength(1)
const perDocument = documentListeners()
expect(perDocument).toBeGreaterThan(0)
const firstHost = document.querySelector(`.${HOST_CLASS}`)
act(() => {
vi.advanceTimersByTime(32)
})
expect(replayed).toEqual(['\x1b[0msnapshot:terminal-0'])
act(() => renderer?.update(createElement('div', null, panes(1))))
expect(startedBuilds).toEqual([true, true])
expect(ready).toEqual(['terminal-0', 'terminal-1'])
expect(refs.size).toBe(2)
expect(documentListeners()).toBe(2 * perDocument)
act(() => {
vi.advanceTimersByTime(32)
})
expect(replayed).toEqual(['\x1b[0msnapshot:terminal-0', '\x1b[0msnapshot:terminal-1'])
act(() => renderer?.update(createElement('div', null, panes(0))))
expect(document.querySelector(`.${HOST_CLASS}`)).toBe(firstHost)
expect(startedBuilds).toEqual([true, true])
expect(ready).toEqual(['terminal-0', 'terminal-1'])
expect(engines.map((engine) => engine.disposals())).toEqual([0, 0])
act(() => renderer?.unmount())
renderer = null
expect(refs.size).toBe(0)
expect(document.querySelectorAll(`.${HOST_CLASS}`)).toHaveLength(0)
expect(documentListeners()).toBe(0)
expect(engines.map((engine) => engine.disposals())).toEqual([1, 1])
} finally {
vi.useRealTimers()
addListener.mockRestore()
removeListener.mockRestore()
}
})
})
describe('the page keeps its own capture buffer', () => {
it('quotes the lines this document captured, and a second mount has none of them', () => {
// Per-mount reporters append before quoting the buffer; embedded modules have no pre-engine head script.