From 52dc32f9ea189b36d499331477f495fc0f42cbca Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Mon, 28 Sep 2026 00:06:35 -0400
Subject: [PATCH] fix(mobile): the page pushes its terminal frame into the
document from RN layout (#23079)
* fix(mobile): keep one mounted terminal frame under every session branch
The frame was keyed so react-native-web would attach its onLayout: a View
that gains onLayout after mount is never observed, and unkeyed the frame
reused the loading branch's View. One contentFrame View now wraps every
branch and carries the handler from the first mount, so no key is
needed. terminalFrame stays on the terminal branch, so nothing else is
clipped.
The parity pin moves: the key string leaves, one View and one style
reference arrive.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): push the page terminal's box into its document from RN layout
The page's document sized itself through a ResizeObserver on its host,
which duplicated RN layout and needed three rules in fit-scale: skip a
0-wide box, skip the last fitted box, and forget that box on any fit
request. The host View's onLayout now pushes into the mount, which is
the page's counterpart of the WebView's window resize.
react-native-web still lays a display:none screen out as 0x0 and its
return as the old box, so the mount treats neither as a change and keeps
answering the last real box while hidden, as a WebView keeps its size.
A fit asked for while hidden therefore lands at once, and fittedBox and
all three rules go. The zero-width wait in applyFitScale stays for a
host that mounts under a hidden screen before its first layout.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold a page terminal's fit while its host is hidden
A fit asked for under a covering screen read the last box, but where
xterm cannot measure cells on a display:none host (its DOM measure, with
no OffscreenCanvas) the retry loop ran out and committed scale 1, and
the show that followed was not a change, so 1 stuck.
The page's rect now says when the host is hidden, and the document holds
any fit asked for then as fitPending instead of committing. The mount
reports the same box coming back as 'shown', distinct from 'resized',
and the document runs a held fit on it and otherwise does nothing, so
pan and zoom still survive a plain hide and show. Native's window
resize reports 'resized' and is never hidden.
The mount also seeds its box from the host, so a first layout that
lands before the mount is not later mistaken for a resize. The hidden
text-scale case now asserts the resized column count, which stale
cells miss.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the page terminal's grid once xterm has sized its cell
The init render check read the grid the moment .xterm-screen existed,
but xterm sizes its one-cell helper textarea only on a cursor move or
resize, after the replay drains. Under full-suite load the read won
that race and measured a 0-wide cell. In the failing runs the fit had
committed at 390/560 on the cell-width gate, so the page was right and
the read was early. It now waits for a sized cell: 8/8 under four-way
parallel load, where it was 4/8.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin that a held page terminal fit runs once
A held fit that lands on show must be spent: a second hide and show
re-running it would reset the pan and zoom the user set in between. The
case now hides and shows again and expects no new transform, which a
commit that stops clearing fitPending fails.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold the refit a narrow hidden text-scale change owes
A text-scale change whose new cells leave fewer than MIN_FIT_COLS in
the box skips the grid resize and returned before any fit. Base cleared
fittedBox there so the next box refit; with that gone, a hidden host
shown at the same box kept the old scale under the larger font. The
branch now asks for the fit while the host is hidden, which holds it
until show. A shown host, and every native one, returns as before.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): fit a text-scale change too large to resize the grid
A visible viewport too narrow for the new cells (280 px at 200%, 18
columns) skipped the resize and returned without a fit, so the larger
text overflowed the fit made for the smaller cells. The branch now fits
unconditionally; applyFitScale already holds the fit while hidden.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
---
...ile-web-app-session-terminal-init.test.mjs | 11 +-
.../session/MobileSessionActiveContent.tsx | 32 +--
.../session/mobile-session-frame-styles.ts | 5 +
.../mobile-session-route-parity.test.ts | 17 +-
...ile-session-terminal-frame-layout.test.tsx | 35 ++-
mobile/src/terminal/TerminalWebView.web.tsx | 12 +-
.../terminal/document/document-host-seams.ts | 16 +-
.../src/terminal/document/document-scope.ts | 6 +-
mobile/src/terminal/document/fit-scale.ts | 33 ++-
.../src/terminal/document/host-seams.test.ts | 9 +-
mobile/src/terminal/document/text-scaling.ts | 4 +-
.../terminal/document/viewport-frame.test.ts | 98 +-------
.../terminal/document/viewport-transform.ts | 2 +-
.../terminal-web-document-mount.test.ts | 237 +++++++++++++++++-
.../terminal/terminal-web-document-mount.ts | 71 ++++--
15 files changed, 424 insertions(+), 164 deletions(-)
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,