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,