diff --git a/config/scripts/mobile-web-app-session-terminal-init.test.mjs b/config/scripts/mobile-web-app-session-terminal-init.test.mjs
index 199c0f15f79..a33cf408fa4 100644
--- a/config/scripts/mobile-web-app-session-terminal-init.test.mjs
+++ b/config/scripts/mobile-web-app-session-terminal-init.test.mjs
@@ -257,8 +257,17 @@ describeRender(
// `.xterm-screen` does: it is created by `term.open()`, which only ever runs from the
// document's `init`. The emulator run had the surface with zero children inside a correctly
// sized container, which is this element missing.
+ // Then the grid readable: xterm sizes its one-cell helper textarea only on a cursor move or
+ // resize, so a read the moment the screen exists can land before the replay has drained.
await page.waitForFunction(
- () => document.querySelector('.xterm-screen') !== null,
+ () => {
+ const cell = document.querySelector('#terminal-surface .xterm-helper-textarea')
+ return (
+ document.querySelector('.xterm-screen') !== null &&
+ cell !== null &&
+ cell.getBoundingClientRect().width > 0
+ )
+ },
undefined,
{
timeout: 60_000,
diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx
index 83cabe1c372..eb7b6c558dd 100644
--- a/mobile/src/session/MobileSessionActiveContent.tsx
+++ b/mobile/src/session/MobileSessionActiveContent.tsx
@@ -81,7 +81,7 @@ export function MobileSessionActiveContent({
toastAnimatedStyle,
createTabBusy
} = controller
- return showLoadingState ? (
+ const content = showLoadingState ? (
@@ -192,19 +192,7 @@ export function MobileSessionActiveContent({
)}
) : (
- {
- terminalFrameHeightRef.current = e.nativeEvent.layout.height
- // Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen.
- const nextWidth = Math.round(e.nativeEvent.layout.width)
- const nextHeight = Math.round(e.nativeEvent.layout.height)
- setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
- notifyTerminalFrameHeight(nextHeight)
- }}
- >
+
{terminals.map((terminal) => (
)
+ return (
+ {
+ terminalFrameHeightRef.current = e.nativeEvent.layout.height
+ // Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen.
+ const nextWidth = Math.round(e.nativeEvent.layout.width)
+ const nextHeight = Math.round(e.nativeEvent.layout.height)
+ setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
+ notifyTerminalFrameHeight(nextHeight)
+ }}
+ >
+ {content}
+
+ )
}
diff --git a/mobile/src/session/mobile-session-frame-styles.ts b/mobile/src/session/mobile-session-frame-styles.ts
index a02c14be014..c6117fc8aae 100644
--- a/mobile/src/session/mobile-session-frame-styles.ts
+++ b/mobile/src/session/mobile-session-frame-styles.ts
@@ -142,6 +142,11 @@ export const mobileSessionFrameStyles = StyleSheet.create({
height: 18,
backgroundColor: colors.borderSubtle
},
+ // The content slot every branch renders in, measured as the terminal's frame; clips nothing.
+ contentFrame: {
+ flex: 1,
+ minHeight: 0
+ },
terminalFrame: {
flex: 1,
minHeight: 0,
diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts
index 87b7dbf369a..4910151d071 100644
--- a/mobile/src/session/mobile-session-route-parity.test.ts
+++ b/mobile/src/session/mobile-session-route-parity.test.ts
@@ -174,13 +174,18 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345
// 529 -> 530, and the host-JSX hash: `key="terminal-frame"`, so the page's frame mounts with its
// onLayout rather than reusing the loading View. Native measured 47 rows before and after: its
// frame reported either way, and its window is its frame, so both measure paths agree there.
+//
+// 531 -> 530, and the host-JSX and style hashes: the key left, and one `contentFrame` View wraps
+// every branch and carries the frame's onLayout, so the page's frame mounts with it. The page measured
+// 47 rows before and after; native was measured only on main's bundle (47), and the wrapper is a
+// flex:1 View around the same flex:1 frame, so its box is the frame's.
const HEAD_RUNTIME_STRING_SHA256 =
- '1be5398cdbdf96b6f7738b63a0e19f536496844140a947939a7f71824b08fe50'
+ '7dd03af1ad61e2f394b8cba35a422f7195de60ae5d608923b52575a56a4dbe42'
// Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now.
-const HEAD_HOST_JSX_SHA256 = 'ca4c8b46af86a05cb671de91c22111c9e4aaeefd4a04c7b8b09976ca01a31c9b'
+const HEAD_HOST_JSX_SHA256 = '1478283a1597c88920aedfea9f6ed13d119ea8f93546e1628d0918cd0a5248ef'
const HEAD_LEAF_JSX_SHA256 = 'c7e1a4b90197697f1eaa640c38da63281b4f7b84fb036ae2152f00c2f7d7cb77'
const HEAD_STYLE_REFERENCE_SHA256 =
- '295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
+ '56a005a1f65b30c11092e3422caef67810e1ec50f66fdd06471c370138b1eeb6'
const HEAD_IDENTITY_FIELD_SHA256 =
'91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6'
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
@@ -620,14 +625,14 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
- expect(strings).toHaveLength(531)
+ expect(strings).toHaveLength(530)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
- expect(jsx.host).toHaveLength(124)
+ expect(jsx.host).toHaveLength(125)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
expect(jsx.leaf).toHaveLength(61)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
- expect(jsx.styleReferences).toHaveLength(172)
+ expect(jsx.styleReferences).toHaveLength(173)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
})
})
diff --git a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx
index 857a6ac561a..80370a44aad 100644
--- a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx
+++ b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx
@@ -45,12 +45,17 @@ import { MobileSessionActiveContent } from './MobileSessionActiveContent'
type Controller = Parameters[0]['controller']
+type Branch = 'loading' | 'pending' | 'terminal'
+
function controller(
- showLoadingState: boolean,
+ branch: Branch | boolean,
notifyTerminalFrameHeight: (height: number) => void
): Controller {
+ const shown = branch === true ? 'loading' : branch === false ? 'terminal' : branch
const scope = {
- showLoadingState,
+ showLoadingState: shown === 'loading',
+ activePendingTerminalTab: shown === 'pending' ? { title: 'Loading terminal' } : null,
+ isPendingTerminalRecoveryParked: false,
showEmptyState: false,
terminals: [],
terminalFrameHeightRef: { current: 0 },
@@ -82,4 +87,30 @@ describe('the terminal frame on the page', () => {
})
expect(heights).toEqual([FRAME.height])
})
+
+ it('stays one mounted frame across loading, a pending terminal and the terminal', () => {
+ // A frame that remounts per branch reports again on every return; one that stays reports once.
+ const heights: number[] = []
+ const notify = (height: number): void => {
+ heights.push(height)
+ }
+ let renderer: ReturnType | undefined
+ const show = (branch: Branch) => {
+ const element = createElement(MobileSessionActiveContent, {
+ controller: controller(branch, notify)
+ })
+ act(() => {
+ if (renderer) {
+ renderer.update(element)
+ } else {
+ renderer = create(element)
+ }
+ })
+ }
+ show('loading')
+ show('terminal')
+ show('pending')
+ show('terminal')
+ expect(heights).toEqual([FRAME.height])
+ })
})
diff --git a/mobile/src/terminal/TerminalWebView.web.tsx b/mobile/src/terminal/TerminalWebView.web.tsx
index c810dbc86d8..a94d700cb9b 100644
--- a/mobile/src/terminal/TerminalWebView.web.tsx
+++ b/mobile/src/terminal/TerminalWebView.web.tsx
@@ -103,6 +103,10 @@ export const TerminalWebView = forwardRef(
// scrollback, and the controller's identity changes with every callback prop.
}, [generation])
+ const handleHostLayout = useCallback(() => {
+ documentRef.current?.notifyViewport()
+ }, [])
+
const handleReload = useCallback(() => {
clearEngineError()
resetReadiness()
@@ -112,7 +116,13 @@ export const TerminalWebView = forwardRef(
return (
-
+ {/* Why: mounted with onLayout, so react-native-web observes it; the document sizes to this box. */}
+
{engineError ? (
) : null}
diff --git a/mobile/src/terminal/document/document-host-seams.ts b/mobile/src/terminal/document/document-host-seams.ts
index ec10e313d43..8046447f370 100644
--- a/mobile/src/terminal/document/document-host-seams.ts
+++ b/mobile/src/terminal/document/document-host-seams.ts
@@ -26,8 +26,13 @@ export type TerminalDocumentViewportRect = {
top: number
width: number
height: number
+ /** The host is `display:none` and the size is the last one it had; a fit waits for it to show. */
+ hidden?: boolean
}
+/** What the host reports: a new box, or the same box back from being hidden. */
+export type TerminalViewportChange = 'resized' | 'shown'
+
/** The document's runtime error reporter, taking the window error handler's own arguments. */
export type TerminalDocumentErrorReporter = (
message: string | (Event & { message?: unknown }),
@@ -65,8 +70,8 @@ export type TerminalDocumentHostSeams = {
hasEngine: () => boolean
/** Every fit, pan, scroll and overlay bound, and every client point mapped into the grid. */
viewportRect: () => TerminalDocumentViewportRect
- /** `fit-scale`: calls back when that box changes size, handing back its removal. */
- observeViewport: (onChange: () => void) => () => void
+ /** `fit-scale`: calls back when that box changes size or is shown again, handing back its removal. */
+ observeViewport: (onChange: (change: TerminalViewportChange) => void) => () => void
/**
* Where this document's elements are: the node its markup was planted in, or null for the page
* the document is running in.
@@ -222,10 +227,11 @@ export function windowViewportRect(): TerminalDocumentViewportRect {
}
/** The WebView's frame changes size exactly when its window does. */
-export function observeWindowViewport(onChange: () => void) {
- window.addEventListener('resize', onChange)
+export function observeWindowViewport(onChange: (change: TerminalViewportChange) => void) {
+ const listener = () => onChange('resized')
+ window.addEventListener('resize', listener)
return function () {
- window.removeEventListener('resize', onChange)
+ window.removeEventListener('resize', listener)
}
}
diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts
index 9be374b2b27..ee3bce4aa56 100644
--- a/mobile/src/terminal/document/document-scope.ts
+++ b/mobile/src/terminal/document/document-scope.ts
@@ -181,8 +181,8 @@ export type TerminalDocumentState = {
removeWebglRecovery: (() => void) | null
/** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */
fitRetryToken: number
- /** `fit-scale`: the viewport box the last fit was committed for, or null before one. */
- fittedBox: { width: number; height: number } | null
+ /** `fit-scale`: the reason of a fit held while the host is hidden, or null when none is owed. */
+ fitPending: string | null
/** `mouse-click-drag`: the mouse gesture in progress, or null. */
mouseGesture: TerminalMouseGesture | null
/** `tap-dispatch`: what the document-level dispatcher has latched onto. */
@@ -308,7 +308,7 @@ function createTerminalDocumentState(): TerminalDocumentState {
removeTapDispatch: null,
removeWebglRecovery: null,
fitRetryToken: 0,
- fittedBox: null,
+ fitPending: null,
mouseGesture: null,
touchDispatch: {
mode: 'idle',
diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts
index 77cbc1a64ec..6fe03360988 100644
--- a/mobile/src/terminal/document/fit-scale.ts
+++ b/mobile/src/terminal/document/fit-scale.ts
@@ -8,6 +8,7 @@ import {
updateTransform
} from './viewport-transform'
import type { TerminalDocumentScope } from './document-scope'
+import type { TerminalViewportChange } from './document-host-seams'
import { scheduleDocumentFrame } from './document-frame-registry'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
@@ -60,23 +61,15 @@ export function adjustRowsForViewport() {}
// so a backgrounded WebView never spins forever.
const FIT_RETRY_MAX_FRAMES = 60
-function isFittedBox(scope: TerminalDocumentScope) {
- const { width, height } = scope.viewportRect()
- const fitted = scope.fittedBox
- return fitted !== null && fitted.width === width && fitted.height === height
-}
-
-function hasViewportWidth(scope: TerminalDocumentScope) {
- const width = scope.viewportRect().width
- return Number.isFinite(width) && width > 0
+function isViewportShown(scope: TerminalDocumentScope) {
+ const { width, hidden } = scope.viewportRect()
+ return hidden !== true && Number.isFinite(width) && width > 0
}
export function applyFitScale(scope: TerminalDocumentScope, reason: string) {
if (!scope.term || !scope.term.element) {
return
}
- // Why: a fit asked for while hidden is dropped until a box arrives, and that box may be the old one.
- scope.fittedBox = null
const token = ++scope.fitRetryToken
let attempts = 0
let lastScrollWidth = -1
@@ -87,8 +80,9 @@ export function applyFitScale(scope: TerminalDocumentScope, reason: string) {
if (!scope.term || !scope.term.element) {
return
}
- // Why: a display:none host measures 0 wide; the fit stays pending until observeViewport reports a box.
- if (!hasViewportWidth(scope)) {
+ // Why: a hidden grid may not measure its cells, so the fit is held until the host is shown.
+ if (!isViewportShown(scope)) {
+ scope.fitPending = reason
return
}
attempts++
@@ -129,8 +123,7 @@ export function commitFitScale(
return
}
const preSnapScale = computeFitScale(scope)
- const { width, height } = scope.viewportRect()
- scope.fittedBox = { width, height }
+ scope.fitPending = null
scope.currentScale = preSnapScale
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
@@ -178,10 +171,12 @@ export function commitFitScale(
* had to copy the five calls into its mount to get it at all (ruling 24).
*/
export function startFitScale(scope: TerminalDocumentScope) {
- const refit = () => {
- // Why: a hidden screen reports 0x0 and then its old box again; native never refits on that, so
- // only a box other than the one last fitted refits, and the user's pan and zoom survive.
- if (!hasViewportWidth(scope) || isFittedBox(scope)) {
+ const refit = (change: TerminalViewportChange) => {
+ // Why: showing the same box again is not a resize; only a fit held while hidden runs, so pan and zoom survive.
+ if (change === 'shown') {
+ if (scope.fitPending !== null) {
+ applyFitScale(scope, scope.fitPending)
+ }
return
}
applyFitScale(scope, 'window-resize')
diff --git a/mobile/src/terminal/document/host-seams.test.ts b/mobile/src/terminal/document/host-seams.test.ts
index 0991f4bb9b1..4ca8f18981e 100644
--- a/mobile/src/terminal/document/host-seams.test.ts
+++ b/mobile/src/terminal/document/host-seams.test.ts
@@ -6,7 +6,7 @@ import { handleMsg } from './host-message-router'
import { notify } from './host-notify'
import { flog } from './viewport-transform'
import { attachWebglAddon } from './webgl-recovery'
-import type { TerminalDocumentHost } from './document-host-seams'
+import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams'
import { documentSourceText } from './document-module-source.test-support'
/**
@@ -299,7 +299,7 @@ describe('the document host seams, once the page sets them', () => {
describe("the document's viewport", () => {
it('refits when the host says its box changed, and not on a window resize it does not own', () => {
- const changes: (() => void)[] = []
+ const changes: ((change: TerminalViewportChange) => void)[] = []
const scope = startedScope({
createTerminal: () => terminalDouble(),
observeViewport: (onChange) => {
@@ -312,7 +312,10 @@ describe("the document's viewport", () => {
window.dispatchEvent(new Event('resize'))
expect(scope.panX).toBe(50)
expect(changes).toHaveLength(1)
- changes[0]!()
+ // The same box shown again with no fit held is not a resize: the pan stays.
+ changes[0]!('shown')
+ expect(scope.panX).toBe(50)
+ changes[0]!('resized')
expect(scope.panX).toBe(0)
})
diff --git a/mobile/src/terminal/document/text-scaling.ts b/mobile/src/terminal/document/text-scaling.ts
index 22d4fd8ed0e..eb6cff4a9f4 100644
--- a/mobile/src/terminal/document/text-scaling.ts
+++ b/mobile/src/terminal/document/text-scaling.ts
@@ -80,8 +80,8 @@ export function applyTextScale(scope: TerminalDocumentScope, scale: number) {
if (cellW > 0 && cellH > 0) {
const cols = Math.floor(scope.viewportRect().width / cellW)
if (cols < MIN_FIT_COLS) {
- // Why: hidden (0 wide) or too narrow; the next box must refit at the new cell size.
- scope.fittedBox = null
+ // Why: too narrow to resize the grid, but the fit still tracks the new cell size; hidden hosts hold it.
+ applyFitScale(scope, 'text-scale')
return
}
const rows = Math.max(8, Math.floor(scope.viewportRect().height / cellH))
diff --git a/mobile/src/terminal/document/viewport-frame.test.ts b/mobile/src/terminal/document/viewport-frame.test.ts
index 8dee5807bc1..063cf964c6f 100644
--- a/mobile/src/terminal/document/viewport-frame.test.ts
+++ b/mobile/src/terminal/document/viewport-frame.test.ts
@@ -2,7 +2,7 @@
import { afterEach, describe, expect, it } from 'vitest'
import { startTerminalDocument, stopTerminalDocument } from './create-terminal-document'
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
-import type { TerminalDocumentHost } from './document-host-seams'
+import type { TerminalDocumentHost, TerminalViewportChange } from './document-host-seams'
import { terminalDocumentDouble } from './document-terminal-double.test-support'
import { handleMsg } from './host-message-router'
import { viewportToMouseReportCell } from './mouse-report-cell'
@@ -110,10 +110,12 @@ describe("the document's frame on the page", () => {
expect(computeFitScale(scope)).toBe(1)
})
- it('commits no fit while the host is hidden, and one once it has a box', async () => {
+ it('commits no fit before the host has a box, and one once it has', async () => {
+ // A page host mounted under a hidden screen has no box until RN lays it out; hide and show after
+ // that are the mount's to absorb (terminal-web-document-mount.test.ts).
let box = { left: 0, top: 0, width: 0, height: 0 }
const widthsRead: number[] = []
- const changes: (() => void)[] = []
+ const changes: ((change: TerminalViewportChange) => void)[] = []
const scope = startedWithGrid({
viewportRect: () => {
widthsRead.push(box.width)
@@ -135,7 +137,7 @@ describe("the document's frame on the page", () => {
await nextFrame()
expect(scales).toEqual([])
box = { left: 0, top: 0, width: 390, height: 600 }
- changes.forEach((onChange) => onChange())
+ changes.forEach((onChange) => onChange('resized'))
await framesUntil(() => scales.length === 2)
// The refit repaints at the scale it has, then the fit commits once: 390 / (7.5 x 55).
expect(scales).toEqual(['1', String(FIT_390)])
@@ -157,92 +159,4 @@ describe("the document's frame on the page", () => {
expect(edge(400)).toBe(0)
expect(edge(120)).toBe(-1)
})
-
- it('keeps pan and zoom across hide and show, and refits when the box really changes', async () => {
- // react-native-screens hides the session when another screen covers it and shows it again at
- // the same size; native never refits on navigation, so the pan the user left must survive.
- let box = { left: 0, top: 0, width: 390, height: 600 }
- const changes: (() => void)[] = []
- const scope = startedWithGrid({
- viewportRect: () => box,
- observeViewport: (onChange) => {
- changes.push(onChange)
- return () => {}
- }
- })
- const resize = async (width: number, height: number) => {
- box = { left: 0, top: 0, width, height }
- changes.forEach((onChange) => onChange())
- await nextFrame()
- await nextFrame()
- }
- await framesUntil(() => scope.currentScale === FIT_390)
- scope.panX = -40
- scope.panY = -30
- scope.userScale = 1.5
- const kept = { panX: -40, panY: -30, userScale: 1.5, currentScale: scope.currentScale }
- const view = () => {
- const { panX, panY, userScale, currentScale } = scope
- return { panX, panY, userScale, currentScale }
- }
-
- await resize(0, 0)
- expect(view()).toEqual(kept)
- await resize(390, 600)
- expect(view()).toEqual(kept)
-
- box = { left: 0, top: 0, width: 300, height: 600 }
- changes.forEach((onChange) => onChange())
- await framesUntil(() => scope.currentScale === 300 / (7.5 * 55))
- expect(view()).toEqual({ panX: 0, panY: 0, userScale: 1, currentScale: 300 / (7.5 * 55) })
- })
-
- it('refits on show when a fit was asked for while hidden, even at the same box', async () => {
- let box = { left: 0, top: 0, width: 390, height: 600 }
- const changes: (() => void)[] = []
- const scope = startedWithGrid({
- viewportRect: () => box,
- observeViewport: (onChange) => {
- changes.push(onChange)
- return () => {}
- }
- })
- const show = (width: number, height: number) => {
- box = { left: 0, top: 0, width, height }
- changes.forEach((onChange) => onChange())
- }
- await framesUntil(() => scope.currentScale === FIT_390)
- show(0, 0)
- handleMsg(scope, { type: 'resize', cols: 80, rows: 40 })
- await nextFrame()
- show(390, 600)
- await framesUntil(() => scope.currentScale !== FIT_390, { required: false })
- expect(scope.currentScale).toBe(390 / (7.5 * 80))
- })
-
- it('refits on show after a text-scale change while hidden', async () => {
- let box = { left: 0, top: 0, width: 390, height: 600 }
- const changes: (() => void)[] = []
- const scope = startedWithGrid({
- viewportRect: () => box,
- observeViewport: (onChange) => {
- changes.push(onChange)
- return () => {}
- }
- })
- const show = (width: number, height: number) => {
- box = { left: 0, top: 0, width, height }
- changes.forEach((onChange) => onChange())
- }
- await framesUntil(() => scope.currentScale === FIT_390)
- show(0, 0)
- handleMsg(scope, { type: 'set-font-scale', fontScale: 0.8 })
- await nextFrame()
- await nextFrame()
- show(390, 600)
- // The smaller font's cells: 55 columns at fontPxForScale(0.8) = 10 px, 7.5 x 10/13 each.
- const fitted = 390 / (7.5 * (10 / 13) * 55)
- await framesUntil(() => scope.currentScale !== FIT_390, { required: false })
- expect(scope.currentScale).toBe(Math.min(1, fitted) >= 0.95 ? 1 : fitted)
- })
})
diff --git a/mobile/src/terminal/document/viewport-transform.ts b/mobile/src/terminal/document/viewport-transform.ts
index 284b2a3d12a..61190307db4 100644
--- a/mobile/src/terminal/document/viewport-transform.ts
+++ b/mobile/src/terminal/document/viewport-transform.ts
@@ -60,7 +60,7 @@ export function computeFitScale(scope: TerminalDocumentScope) {
return 1
}
const vpWidth = scope.viewportRect().width
- // Why: a host hidden with display:none measures 0; a 0 scale would blank it when shown again.
+ // Why: a viewport with no width yet (a page host never laid out) would give scale 0 and blank the grid.
if (vpWidth <= 0) {
return 1
}
diff --git a/mobile/src/terminal/terminal-web-document-mount.test.ts b/mobile/src/terminal/terminal-web-document-mount.test.ts
index 1ce4aa3d40b..9123e0f21b6 100644
--- a/mobile/src/terminal/terminal-web-document-mount.test.ts
+++ b/mobile/src/terminal/terminal-web-document-mount.test.ts
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
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 { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
/**
@@ -18,6 +20,8 @@ import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
*/
/** Set for the length of one case; the factory throws it instead of building a document. */
let startThrows: Error | null = null
+/** Set for the length of one case; the document builds its terminals here instead of xterm. */
+let gridTerminal: (() => TerminalDocumentTerminal) | null = null
vi.mock('./document/create-terminal-document', async (importOriginal) => {
const actual = await importOriginal()
@@ -27,7 +31,8 @@ vi.mock('./document/create-terminal-document', async (importOriginal) => {
if (startThrows) {
throw startThrows
}
- return actual.createTerminalDocument(host)
+ const grid = gridTerminal
+ return actual.createTerminalDocument(grid ? { ...host, createTerminal: grid } : host)
}
}
})
@@ -61,9 +66,11 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 50))
const INIT = { type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false } as const
let renderer: ReactTestRenderer | null = null
+let restoreTransform: (() => void) | null = null
beforeEach(() => {
startThrows = null
+ gridTerminal = null
document.body.innerHTML = ''
document.head.innerHTML = ''
})
@@ -71,6 +78,8 @@ beforeEach(() => {
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
+ restoreTransform?.()
+ restoreTransform = null
})
describe('a stopped document takes its engines with it', () => {
@@ -244,3 +253,229 @@ describe('the component names the cause of a start that threw', () => {
expect(engineErrors).toEqual([])
})
})
+
+const CELL = { width: 7.5, height: 15 }
+const FIT_390 = 390 / (7.5 * 55)
+
+/**
+ * A laid-out 55x40 grid whose cells scale with the font, as xterm's do, or read 0 while `cells`
+ * says they cannot be measured: xterm's DOM measure on a `display:none` host, where no
+ * OffscreenCanvas is available.
+ */
+function gridDouble(cells: { measurable: boolean }): TerminalDocumentTerminal {
+ const terminal = terminalDocumentDouble().terminal
+ const cell = () => {
+ const k = cells.measurable ? terminal.options.fontSize / 13 : 0
+ return { width: CELL.width * k, height: CELL.height * k }
+ }
+ const grid = Object.assign(terminal, {
+ cols: 55,
+ rows: 40,
+ _core: {
+ _renderService: {
+ get dimensions() {
+ return { css: { cell: cell() } }
+ }
+ }
+ }
+ })
+ grid.resize = (cols: number, rows: number) => {
+ grid.cols = cols
+ grid.rows = rows
+ }
+ return grid
+}
+
+/**
+ * A mounted page document over a grid, whose host box the case sets and pushes as RN layout does:
+ * react-native-web's `onLayout` reports a `display:none` screen as 0x0 and its return as the old box.
+ */
+async function mountedOverGrid({ laidOutFirst = true } = {}) {
+ const cells = { measurable: true }
+ const grids: TerminalDocumentTerminal[] = []
+ gridTerminal = () => {
+ const grid = gridDouble(cells)
+ grids.push(grid)
+ return grid
+ }
+ const host = plantHost()
+ let box = { width: 390, height: 600 }
+ host.getBoundingClientRect = () => new DOMRect(0, 134, box.width, box.height)
+ const mounted = mountTerminalWebDocument(host, () => {})
+ // Every surface transform the document writes; a refit writes one even when nothing moved. Read
+ // off the style prototype because an init swaps the surface element for a fresh one.
+ const scales: number[] = []
+ const proto: object = Object.getPrototypeOf(host.style)
+ const own = Object.getOwnPropertyDescriptor(proto, 'transform')!
+ const write = own.set!
+ Object.defineProperty(proto, 'transform', {
+ ...own,
+ set(this: CSSStyleDeclaration, value: string) {
+ const scale = /scale\(([^)]*)\)/.exec(String(value))
+ if (scale) {
+ scales.push(Number(scale[1]))
+ }
+ write.call(this, value)
+ }
+ })
+ restoreTransform = () => Object.defineProperty(proto, 'transform', own)
+ let id = 0
+ const send = (command: TerminalWebViewCommand) => mounted.send({ ...command, id: ++id })
+ const layOut = (width: number, height: number) => {
+ box = { width, height }
+ mounted.notifyViewport()
+ }
+ if (laidOutFirst) {
+ layOut(390, 600)
+ }
+ send({ type: 'init', cols: 55, rows: 40, initialData: '', preserveScroll: false })
+ await framesUntil(() => scales.at(-1) === FIT_390)
+ return { mounted, scales, send, layOut, cells, grid: () => grids.at(-1)! }
+}
+
+const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()))
+
+async function framesUntil(done: () => boolean, frames = 30) {
+ for (let frame = 0; frame < frames && !done(); frame++) {
+ await nextFrame()
+ }
+ expect(done()).toBe(true)
+}
+
+describe("the page pushes its terminal frame's box into the document", () => {
+ it('refits on a box RN laid out, through the host View, with no ResizeObserver of its own', () => {
+ let observers = 0
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor() {
+ observers += 1
+ }
+ observe() {}
+ disconnect() {}
+ }
+ )
+ try {
+ const host = plantHost()
+ let height = 600
+ host.getBoundingClientRect = () => new DOMRect(0, 134, 390, height)
+ act(() => {
+ renderer = create(createElement(TerminalWebView, {}), { createNodeMock: () => host })
+ })
+ const surface = host.querySelector('#terminal-surface')!
+ expect(surface.style.transform).toBe('')
+ const laidOut = renderer!.root.findAll((node) => typeof node.props.onLayout === 'function')
+ expect(laidOut).toHaveLength(1)
+ height = 560
+ act(() => {
+ laidOut[0]!.props.onLayout({ nativeEvent: { layout: { width: 390, height: 560 } } })
+ })
+ expect(surface.style.transform).toContain('scale(1)')
+ expect(observers).toBe(0)
+ } finally {
+ vi.unstubAllGlobals()
+ }
+ })
+
+ it('keeps pan and zoom across hide and show, and refits when the box really changes', async () => {
+ // Native never refits on navigation: its WebView keeps its size while another screen covers it.
+ const { mounted, scales, layOut } = await mountedOverGrid()
+ const fitted = scales.length
+ layOut(0, 0)
+ layOut(390, 600)
+ await nextFrame()
+ await nextFrame()
+ expect(scales).toHaveLength(fitted)
+
+ layOut(300, 600)
+ await framesUntil(() => scales.at(-1) === 300 / (7.5 * 55))
+ mounted.dispose()
+ })
+
+ it('keeps pan and zoom when RN laid the host out before the mount existed', async () => {
+ // The first layout can land before the document does; the box it reported is still the box.
+ const { mounted, scales, layOut } = await mountedOverGrid({ laidOutFirst: false })
+ const fitted = scales.length
+ layOut(0, 0)
+ layOut(390, 600)
+ await nextFrame()
+ await nextFrame()
+ expect(scales).toHaveLength(fitted)
+ mounted.dispose()
+ })
+
+ it('holds a fit asked for while hidden and lands it on show', async () => {
+ const { mounted, scales, send, layOut } = await mountedOverGrid()
+ layOut(0, 0)
+ send({ type: 'resize', cols: 80, rows: 40 })
+ await nextFrame()
+ await nextFrame()
+ expect(scales.at(-1)).toBe(FIT_390)
+ layOut(390, 600)
+ await framesUntil(() => scales.at(-1) === 390 / (7.5 * 80))
+ // The held fit is spent: a second hide and show must not run it again over the user's pan.
+ const landed = scales.length
+ layOut(0, 0)
+ layOut(390, 600)
+ await nextFrame()
+ await nextFrame()
+ expect(scales).toHaveLength(landed)
+ mounted.dispose()
+ })
+
+ it('never commits a blind fit when the grid cannot be measured while hidden', async () => {
+ // A reconnect re-inits under a covering screen, where xterm's DOM measure reads every cell as 0.
+ const { mounted, scales, send, layOut, cells } = await mountedOverGrid()
+ layOut(0, 0)
+ const shown = scales.length
+ cells.measurable = false
+ send({ type: 'init', cols: 55, rows: 40, initialData: '', preserveScroll: false })
+ // Past the retry loop's 60-frame cap, where a fit that did not wait commits scale 1.
+ for (let frame = 0; frame < 75; frame++) {
+ await nextFrame()
+ }
+ expect(scales.slice(shown)).not.toContain(1)
+ cells.measurable = true
+ layOut(390, 600)
+ await framesUntil(() => scales.at(-1) === FIT_390)
+ mounted.dispose()
+ })
+
+ it('fits a text scale too large to resize the grid while visible', async () => {
+ const { mounted, scales, send, layOut, grid } = await mountedOverGrid()
+ layOut(280, 600)
+ await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
+ // fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
+ send({ type: 'set-font-scale', fontScale: 2 })
+ await framesUntil(() => scales.at(-1) === 280 / (15 * 55))
+ expect(grid().cols).toBe(55)
+ mounted.dispose()
+ })
+
+ it('refits on show after a text scale too large to resize the grid while hidden', async () => {
+ const { mounted, scales, send, layOut } = await mountedOverGrid()
+ layOut(280, 600)
+ await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
+ layOut(0, 0)
+ // fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
+ send({ type: 'set-font-scale', fontScale: 2 })
+ await nextFrame()
+ await nextFrame()
+ layOut(280, 600)
+ await framesUntil(() => scales.at(-1) === 280 / (15 * 55))
+ mounted.dispose()
+ })
+
+ it('resizes the grid to a text scale changed while hidden, and fits it on show', async () => {
+ const { mounted, scales, send, layOut, grid } = await mountedOverGrid()
+ layOut(0, 0)
+ send({ type: 'set-font-scale', fontScale: 0.8 })
+ // fontPxForScale(0.8) = 10 px: 390 / (7.5 x 10/13) = 67 columns, where stale cells give 52.
+ await framesUntil(() => grid().cols === 67)
+ const hidden = scales.length
+ layOut(390, 600)
+ await framesUntil(() => scales.length > hidden)
+ expect(scales.at(-1)).toBe(1)
+ mounted.dispose()
+ })
+})
diff --git a/mobile/src/terminal/terminal-web-document-mount.ts b/mobile/src/terminal/terminal-web-document-mount.ts
index c1b13e38657..f490fba7818 100644
--- a/mobile/src/terminal/terminal-web-document-mount.ts
+++ b/mobile/src/terminal/terminal-web-document-mount.ts
@@ -2,6 +2,7 @@ import { Terminal } from '@xterm/xterm'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
import type { TerminalDocumentTerminal } from './document/document-terminal-shape'
+import type { TerminalViewportChange } from './document/document-host-seams'
import { TERMINAL_DOCUMENT_ELEMENT_STYLE, TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
import { scopeStyleToHost } from '../style-scoping/document-style-scoping'
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
@@ -25,6 +26,8 @@ import type { TerminalWebViewCommand } from './terminal-webview-messages'
export type TerminalWebDocument = {
/** Hands one host command to the document, as a bridge frame does inside the WebView. */
send: (command: TerminalWebViewCommand & { id: number }) => void
+ /** RN laid the host out again: the page's counterpart of the WebView's window resize. */
+ notifyViewport: () => void
dispose: () => void
}
@@ -102,12 +105,14 @@ export function mountTerminalWebDocument(
ensureDocumentStyle()
host.classList.add(HOST_CLASS)
host.innerHTML = TERMINAL_DOCUMENT_MARKUP
- const started = startDocumentOrGiveTheHostBack(host, receive)
+ const viewport = pageViewport(host)
+ const started = startDocumentOrGiveTheHostBack(host, receive, viewport)
return {
send: (command) => {
started.send(command)
},
+ notifyViewport: viewport.notify,
dispose: () => {
started.stop()
host.innerHTML = ''
@@ -128,10 +133,11 @@ export function mountTerminalWebDocument(
*/
function startDocumentOrGiveTheHostBack(
host: HTMLElement,
- receive: (message: Record) => void
+ receive: (message: Record) => void,
+ viewport: PageViewport
) {
try {
- return startPageDocument(host, receive)
+ return startPageDocument(host, receive, viewport)
} catch (error) {
host.innerHTML = ''
host.classList.remove(HOST_CLASS)
@@ -139,8 +145,52 @@ function startDocumentOrGiveTheHostBack(
}
}
+type PageViewport = ReturnType
+
+/**
+ * The host's box, pushed by RN layout. RN web lays a `display:none` host out as 0x0 and its return
+ * as the same box: the size stays the last real one, as a covered WebView's does, and the return is
+ * a show rather than a resize. Sizes are the client rect's; RN web's are whole-pixel `offsetWidth`s.
+ */
+function pageViewport(host: HTMLElement) {
+ // Seeded from the host, since RN's first layout can land before this mount exists.
+ const seed = host.getBoundingClientRect()
+ let laidOut = { width: seed.width, height: seed.height }
+ let onChange: ((change: TerminalViewportChange) => void) | null = null
+ return {
+ rect: () => {
+ const box = host.getBoundingClientRect()
+ const hidden = box.width <= 0
+ const size = hidden ? laidOut : box
+ return { left: box.left, top: box.top, width: size.width, height: size.height, hidden }
+ },
+ observe: (change: (change: TerminalViewportChange) => void) => {
+ onChange = change
+ return () => {
+ onChange = null
+ }
+ },
+ notify: () => {
+ const { width, height } = host.getBoundingClientRect()
+ if (width <= 0) {
+ return
+ }
+ if (width === laidOut.width && height === laidOut.height) {
+ onChange?.('shown')
+ return
+ }
+ laidOut = { width, height }
+ onChange?.('resized')
+ }
+ }
+}
+
/** The eleven seams, as the page answers them. */
-function startPageDocument(host: HTMLElement, receive: (message: Record) => void) {
+function startPageDocument(
+ host: HTMLElement,
+ receive: (message: Record) => void,
+ viewport: PageViewport
+) {
// Written by this document's own reporter: `startHostNotify` installs it through the seam below,
// which here is a `window` error listener, and every error it forwards is appended before the
// report that quotes it. What the page cannot have is the WebView head's half — a buffer open
@@ -187,17 +237,10 @@ function startPageDocument(host: HTMLElement, receive: (message: Record true,
// The window here is the whole page, header and dock included; the grid is shown in the host.
- viewportRect: () => {
- const box = host.getBoundingClientRect()
- return { left: box.left, top: box.top, width: box.width, height: box.height }
- },
+ viewportRect: viewport.rect,
- // The host resizes without the window: a dock growing, a panel docking beside it.
- observeViewport: (onChange) => {
- const observer = new ResizeObserver(onChange)
- observer.observe(host)
- return () => observer.disconnect()
- },
+ // The host resizes without the window, and RN layout is what says so: the component pushes it.
+ observeViewport: viewport.observe,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shape is xterm's own, except that `getCell` takes back the cell xterm allocated and the document declares only the members it reads on one.
createTerminal: (options) => new Terminal(options) as unknown as TerminalDocumentTerminal,