From b7b1b71cae581ea7bd5e9fde249082d754dbbc4e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Jul 2026 13:22:10 -0700 Subject: [PATCH] fix(FN-7692): recover blank mobile terminal when xterm screen collapses to 0x0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile terminal rendered blank even though the WebSocket was Connected and the shell prompt had already streamed in. Root cause (reproduced live): on the mobile fullscreen layout xterm's CharSizeService can measure a 0-width character cell, so FitAddon.fit() proposes 0 columns/rows and .xterm-screen (plus the WebGL canvas) collapses to 0x0 — prompt bytes arrive and are written into xterm's row DOM but paint into a zero-size box. Renderer-independent and mobile-layout- specific; not fixed by resize/font-size re-fits because prior guards only validate the container width and font load, never the resulting measured screen/cell width. Add guardAgainstCollapsedTerminalScreen (app/utils/terminalPreferences.ts) and arm it from both terminal surfaces (TerminalModal + SessionTerminal) right after their initial fit. While the container has a width but .xterm-screen does not, it forces a genuine DOM-strategy remeasure (forceTerminalFontRemeasure) + fit, re-driven by a ResizeObserver until the screen has a real width. It waits (does not give up) while the container is not yet measurable, is bounded so it never spins, and is disposed on every re-init/close path. Recurrence of FN-7620/FN-7686. - Add isTerminalScreenCollapsed + guardAgainstCollapsedTerminalScreen with tests - Wire + dispose the guard across all xterm (re)init/close paths in both surfaces - Add changeset (patch) and a docs/solutions write-up Note: reproduced via mobile emulation (393px, iPhone UA, forced touch), not a physical device; the guard is the structural fix — confirm on a real device. Fusion-Task-Id: FN-7692 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fn-7692-mobile-terminal-blank-screen.md | 7 + ...rminal-collapsed-screen-zero-width-cell.md | 74 ++++++++++ .../app/components/SessionTerminal.tsx | 28 ++++ .../app/components/TerminalModal.tsx | 38 +++++ .../__tests__/terminalPreferences.test.ts | 138 +++++++++++++++++- .../app/utils/terminalPreferences.ts | 121 +++++++++++++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7692-mobile-terminal-blank-screen.md create mode 100644 docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md diff --git a/.changeset/fn-7692-mobile-terminal-blank-screen.md b/.changeset/fn-7692-mobile-terminal-blank-screen.md new file mode 100644 index 0000000000..e5d8b8bc6e --- /dev/null +++ b/.changeset/fn-7692-mobile-terminal-blank-screen.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the terminal rendering blank on mobile even though the shell prompt already loaded. +category: fix +dev: Adds `guardAgainstCollapsedTerminalScreen` (app/utils/terminalPreferences.ts), wired into TerminalModal and SessionTerminal. On the mobile fullscreen layout xterm could measure a 0-width character cell, so FitAddon.fit() proposed 0 columns/rows and `.xterm-screen` (plus the WebGL canvas) collapsed to 0x0 — prompt bytes arrived but painted into a zero-size box. The guard watches for that collapsed state (container has width, `.xterm-screen` does not) and forces a genuine remeasure+fit, re-driven by a ResizeObserver until the screen has a real width. Renderer-agnostic and bounded. Recurrence of FN-7620/FN-7686. diff --git a/docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md b/docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md new file mode 100644 index 0000000000..02ed7fd912 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-terminal-collapsed-screen-zero-width-cell.md @@ -0,0 +1,74 @@ +--- +title: "Mobile terminal renders blank because xterm's screen collapses to 0x0" +date: 2026-07-08 +category: ui-bugs +module: packages/dashboard/app/utils/terminalPreferences.ts +problem_type: rendering +component: embedded_terminal +applies_when: "The standalone (TerminalModal) or task-session (SessionTerminal) terminal shows nothing on mobile even though the WebSocket says Connected and the prompt has arrived." +symptoms: + - "Mobile terminal is blank for many seconds (or indefinitely) after opening" + - "Header shows Connected; no prompt text is visible" + - ".xterm-screen has style width:0px;height:0px while the .xterm container has a real size" +root_cause: xterm_charsize_service_measured_zero_width_cell_on_mobile_layout +resolution_type: measurement_validity_guard +severity: high +related_components: + - packages/dashboard/app/components/TerminalModal.tsx + - packages/dashboard/app/components/SessionTerminal.tsx +tags: [terminal, xterm, mobile, fit-addon, char-size, blank-screen, fn-7620, fn-7686, fn-7692] +--- + +# Mobile terminal renders blank: xterm screen collapses to 0x0 + +## Problem + +On the mobile fullscreen terminal layout the terminal opens, the WebSocket connects, the shell prompt +data streams in and is written into xterm's row DOM — but the terminal is visibly blank. It is NOT a +network, PTY, or login-shell latency problem (measured live: `POST /terminal/sessions` 3ms, WS first +prompt bytes ~215ms, desktop renders <1s). + +## Root cause + +`terminal.open()` + `FitAddon.fit()` can run while xterm's `CharSizeService` resolves the character +cell to **0 width**. `FitAddon.proposeDimensions()` then yields 0 columns/rows and `.xterm-screen` +(and, on desktop, the WebGL renderer canvas) collapses to **0x0**. The prompt is painted into a +zero-size box, so it is invisible. Reproduced live via mobile emulation: the `.xterm` container +measured 385x758 while `.xterm-screen` stayed `width:0px;height:0px` and xterm's own +`.xterm-char-measure-element` read 0 — while an identical monospace span in the same container measured +~295px. So the correct measurement is achievable and xterm is holding a stale 0. + +The bug is: + +- **Renderer-independent** — reproduced on both the DOM renderer (touch/mobile) and the WebGL renderer. +- **Mobile-layout-specific** — desktop widths render immediately. +- **Not self-healing** — a `window.resize` and a font-size change both re-fit but do not recover, because + every prior guard validates the *container* width (`clientWidth > 0`) and font load + (`waitForTerminalFontMetrics`), never the *resulting* measured screen/cell width, and a single 0 + measurement is cached and never re-validated. + +## Fix + +`guardAgainstCollapsedTerminalScreen(container, terminal, fit, fontFamily)` in +`app/utils/terminalPreferences.ts`, armed by both TerminalModal and SessionTerminal right after their +initial fit: + +- Detects the collapsed state: container has a width but `.xterm-screen` does not + (`isTerminalScreenCollapsed`). +- Forces a genuine `CharSizeService` remeasure via `withDomBasedTerminalCharacterMeasurement(() => + forceTerminalFontRemeasure(...))` followed by `fit()`. +- Re-driven by a `ResizeObserver` so it re-attempts exactly when the mobile modal/keyboard geometry + finally settles (it waits, rather than giving up, while the container itself is not yet measurable). +- Bounded by `maxAttempts` so it never spins, and a no-op once the screen has a non-zero width. +- Tied to the xterm instance lifetime — disposed on every re-init/close/reinitialize path. + +## Verification + +- Unit tests in `app/utils/__tests__/terminalPreferences.test.ts` + (describe "guardAgainstCollapsedTerminalScreen"): classifies collapse only when the container is + measurable; forces remeasure+fit and stops once the screen reports a width; waits while the container + is not yet measurable and recovers on the ResizeObserver relayout; stays bounded when the screen never + recovers; no-op when already healthy. +- Physical-device note: the root cause and DOM signature were reproduced in the automation browser via + mobile emulation (393px, iPhone UA, forced touch), not a physical iPhone. Confirm on a real device + when possible. diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index f81c358432..4725cfcb8a 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -11,11 +11,13 @@ import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; import { TERMINAL_PREFERENCES_KEY, forceTerminalFontRemeasure, + guardAgainstCollapsedTerminalScreen, readTerminalPreferences, resolveTerminalFontFamily, resolveTerminalGlyphFontFamily, waitForTerminalFontMetrics, withDomBasedTerminalCharacterMeasurement, + type TerminalScreenGuardHandle, } from "../utils/terminalPreferences"; /** @@ -192,6 +194,8 @@ export function SessionTerminal({ const containerRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); + /** FN-7692: active collapsed-screen guard for the current xterm; disposed on teardown. */ + const screenGuardRef = useRef(null); const wsRef = useRef(null); const [postureTooltipOpen, setPostureTooltipOpen] = useState(false); @@ -508,6 +512,28 @@ export function SessionTerminal({ /* container not measurable yet */ } + /* + FNXC:Terminal 2026-07-08-16:20: + FN-7692: arm the collapsed-screen guard on this attach surface too. The task-session terminal + opens inside the same mobile fullscreen layout, so its initial open()+fit() can likewise measure a + 0-width cell and collapse .xterm-screen to 0x0 — prompt bytes stream in but paint into a zero-size + box (blank terminal). Force a genuine remeasure+fit (re-driven by a ResizeObserver as the layout + settles) until the screen has a real width. Tied to this xterm instance; disposed on teardown. + */ + screenGuardRef.current?.dispose(); + if (containerRef.current) { + screenGuardRef.current = guardAgainstCollapsedTerminalScreen( + containerRef.current, + term, + () => { + if (disposed || xtermRef.current !== term) return; + (fitAddon as unknown as { fit: () => void }).fit(); + sendResize(term.cols, term.rows); + }, + resolvedFontFamily, + ); + } + void (async () => { const fontMetricsSettled = await waitForTerminalFontMetrics( terminalPreferences.fontSize, @@ -684,6 +710,8 @@ export function SessionTerminal({ } wsRef.current = null; } + screenGuardRef.current?.dispose(); // FN-7692: stop the collapsed-screen watchdog on teardown + screenGuardRef.current = null; const term = xtermRef.current; if (term) { try { diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index dd2b73dc9c..3c4b30b5dd 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -42,6 +42,7 @@ import { TERMINAL_FONT_FAMILY_PRESETS, clampTerminalFontSize, forceTerminalFontRemeasure, + guardAgainstCollapsedTerminalScreen, readTerminalPreferences, resolveTerminalFontFamily, resolveTerminalGlyphFontFamily, @@ -50,6 +51,7 @@ import { writeTerminalPreferences, type TerminalPreferences, type TerminalRenderer, + type TerminalScreenGuardHandle, } from "../utils/terminalPreferences"; import "@xterm/xterm/css/xterm.css"; @@ -606,6 +608,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const initializedRendererRef = useRef(terminalPreferences.renderer); /** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */ const pendingFitRef = useRef(null); + /** + * FN-7692: active collapsed-screen guard for the current xterm instance. Disposed and re-armed on + * every (re)init so the mobile 0x0-screen watchdog never outlives its terminal. See + * `guardAgainstCollapsedTerminalScreen`. + */ + const screenGuardRef = useRef(null); /* FNXC:Terminal 2026-06-22-09:00: Docked-resize, floating-drag, and floating-resize each attach pointer listeners and schedule a rAF for the duration of a drag. If the modal closes or the component unmounts mid-drag, those listeners + the pending frame would leak. Track the active drag teardown here and run it from the close/unmount effect. @@ -1388,6 +1396,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG if (xtermRef.current && (xtermInitializedRef.current !== currentSessionId || projectChanged)) { xtermRef.current.dispose(); xtermRef.current = null; + screenGuardRef.current?.dispose(); // FN-7692: guard is tied to the disposed xterm instance + screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { @@ -1558,6 +1568,28 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermInitializedRef.current = currentSessionId; void remeasureAfterTerminalFontLoad(currentSessionId, terminal, fitAddon); + /* + FNXC:Terminal 2026-07-08-16:20: + FN-7692: arm the collapsed-screen guard for this instance. On the mobile fullscreen layout the + initial open()+fit() above can measure a 0-width cell and collapse .xterm-screen to 0x0, leaving + the terminal blank even though the prompt already streamed in. The guard forces a genuine + remeasure+fit (re-driven by a ResizeObserver as the mobile modal/keyboard geometry settles) until + the screen has a real width. Dispose any prior guard first so it is tied to this xterm's lifetime. + */ + screenGuardRef.current?.dispose(); + if (terminalRef.current) { + screenGuardRef.current = guardAgainstCollapsedTerminalScreen( + terminalRef.current, + terminal, + () => { + if (xtermInitializedRef.current !== currentSessionId) return; + fitAddon.fit(); + resizeRef.current?.(terminal.cols, terminal.rows); + }, + fontFamilyAtInit, + ); + } + // If the virtual keyboard opened while xterm was still in async // initialization for this tab, force a post-init fit so this new // session uses the already-constrained mobile modal height. @@ -1703,6 +1735,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermRef.current.dispose(); xtermRef.current = null; } + screenGuardRef.current?.dispose(); // FN-7692: stop the collapsed-screen watchdog on close + screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { @@ -2089,6 +2123,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermRef.current.dispose(); xtermRef.current = null; } + screenGuardRef.current?.dispose(); // FN-7692: re-armed by the re-running init effect + screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { @@ -2154,6 +2190,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG xtermRef.current.dispose(); xtermRef.current = null; } + screenGuardRef.current?.dispose(); // FN-7692: drop the watchdog before a manual re-init + screenGuardRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; if (windowResizeListenerRef.current) { diff --git a/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts b/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts index 70431c3b95..9c9e42685b 100644 --- a/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts +++ b/packages/dashboard/app/utils/__tests__/terminalPreferences.test.ts @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_TERMINAL_PREFERENCES, LEGACY_TERMINAL_FONT_SIZE_KEY, TERMINAL_PREFERENCES_KEY, XTERM_FONT_FAMILY, forceTerminalFontRemeasure, + guardAgainstCollapsedTerminalScreen, + isTerminalScreenCollapsed, readTerminalPreferences, waitForTerminalFontMetrics, writeTerminalPreferences, @@ -169,4 +171,138 @@ describe("terminalPreferences", () => { expect(terminal.options.fontFamily).toBe("system-mono, monospace"); }); }); + + /* + FN-7692 regression: the mobile terminal rendered blank because xterm's `.xterm-screen` collapsed to + 0x0 (character cell measured 0) even though the prompt data had already arrived. These tests pin the + guard that detects the collapsed state (container has width, screen does not), forces a genuine + remeasure + fit until the screen has a real width, waits (does not give up) while the container is not + yet measurable, and stays bounded when the screen never recovers. Symptom under test = a screen stuck + at width 0; assertion it is gone = a forced remeasure+fit runs until the screen reports a width. + */ + describe("guardAgainstCollapsedTerminalScreen", () => { + let rafQueue: Array<() => void>; + let resizeObservers: Array<{ cb: () => void; disconnected: boolean }>; + + beforeEach(() => { + rafQueue = []; + resizeObservers = []; + vi.stubGlobal("requestAnimationFrame", (cb: () => void) => { + rafQueue.push(cb); + return rafQueue.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(cb: () => void) { + resizeObservers.push({ cb, disconnected: false }); + } + observe(): void {} + disconnect(): void { + const entry = resizeObservers[resizeObservers.length - 1]; + if (entry) entry.disconnected = true; + } + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function flushRaf(max = 200): void { + let n = 0; + while (rafQueue.length && n++ < max) { + const cb = rafQueue.shift(); + cb?.(); + } + } + + function makeContainer(init: { containerWidth: number; screenWidth: number }) { + const state = { ...init }; + const screen = { + getBoundingClientRect: () => ({ width: state.screenWidth }) as DOMRect, + }; + const container = { + getBoundingClientRect: () => ({ width: state.containerWidth }) as DOMRect, + querySelector: (sel: string) => (sel === ".xterm-screen" ? screen : null), + } as unknown as HTMLElement; + return { container, state }; + } + + it("classifies collapse only when the container is measurable but the screen is not", () => { + expect(isTerminalScreenCollapsed(385, 0)).toBe(true); + expect(isTerminalScreenCollapsed(385, 300)).toBe(false); + expect(isTerminalScreenCollapsed(0, 0)).toBe(false); + }); + + it("forces a remeasure + fit and stops once the screen reports a width", () => { + const { container, state } = makeContainer({ containerWidth: 385, screenWidth: 0 }); + const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; + let fitCalls = 0; + // A genuine remeasure+fit recovers the collapsed screen on the first attempt. + const fit = () => { + fitCalls += 1; + state.screenWidth = 300; + }; + + guardAgainstCollapsedTerminalScreen(container, terminal, fit, XTERM_FONT_FAMILY); + + expect(fitCalls).toBe(1); + expect(state.screenWidth).toBe(300); + // Recovered synchronously on the initial kick — nothing left scheduled. + expect(rafQueue.length).toBe(0); + }); + + it("does not act while the container itself is not yet measurable, then recovers on relayout", () => { + const { container, state } = makeContainer({ containerWidth: 0, screenWidth: 0 }); + const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; + let fitCalls = 0; + const fit = () => { + fitCalls += 1; + state.screenWidth = 300; + }; + + guardAgainstCollapsedTerminalScreen(container, terminal, fit, XTERM_FONT_FAMILY); + // Container has no width yet (mobile modal not settled): must wait, not give up. + expect(fitCalls).toBe(0); + expect(resizeObservers[0]?.disconnected).toBe(false); + + // Container becomes measurable; the ResizeObserver re-drives the guard. + state.containerWidth = 385; + resizeObservers[0].cb(); + + expect(fitCalls).toBe(1); + expect(state.screenWidth).toBe(300); + }); + + it("stays bounded (never spins) when the screen never recovers", () => { + const { container } = makeContainer({ containerWidth: 385, screenWidth: 0 }); + const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; + let fitCalls = 0; + const fit = () => { + fitCalls += 1; // never sets a width — the collapse persists + }; + + guardAgainstCollapsedTerminalScreen(container, terminal, fit, XTERM_FONT_FAMILY, { + maxAttempts: 3, + }); + flushRaf(); + + expect(fitCalls).toBe(3); + expect(rafQueue.length).toBe(0); + }); + + it("is a no-op when the screen already has a width at mount", () => { + const { container } = makeContainer({ containerWidth: 385, screenWidth: 300 }); + const terminal = { options: { fontFamily: XTERM_FONT_FAMILY } }; + let fitCalls = 0; + + guardAgainstCollapsedTerminalScreen(container, terminal, () => (fitCalls += 1), XTERM_FONT_FAMILY); + + expect(fitCalls).toBe(0); + expect(rafQueue.length).toBe(0); + }); + }); }); diff --git a/packages/dashboard/app/utils/terminalPreferences.ts b/packages/dashboard/app/utils/terminalPreferences.ts index 38e2aac000..13e1bfc9ba 100644 --- a/packages/dashboard/app/utils/terminalPreferences.ts +++ b/packages/dashboard/app/utils/terminalPreferences.ts @@ -294,6 +294,127 @@ export function withDomBasedTerminalCharacterMeasurement(fn: () => T): T { } } +/* +FNXC:Terminal 2026-07-08-16:20: +FN-7692 (mobile blank-terminal, recurrence of FN-7620/FN-7686): Both terminal surfaces (standalone +TerminalModal and task-session SessionTerminal) can run `terminal.open()` + `FitAddon.fit()` on the +mobile fullscreen layout while xterm's `CharSizeService` resolves the character cell to 0 width. When +the cell width is 0, `FitAddon.proposeDimensions()` yields 0 columns/rows and `.xterm-screen` (plus the +WebGL renderer canvas) collapses to 0x0 — the shell prompt DOES arrive over the WebSocket and IS written +into xterm's row DOM, but it is painted into a zero-size box, so the terminal looks permanently blank +("view renders but no output for many seconds" on mobile). Reproduced live: `.xterm` container measured +385x758 while `.xterm-screen` stayed `width:0px;height:0px` and xterm's own `.xterm-char-measure-element` +read 0 even though an identical monospace span in the same container measured ~295px — i.e. the correct +measurement is achievable and xterm is holding a stale 0. The bug is renderer-independent (repro'd on both +DOM and WebGL renderers) and mobile-layout-specific (desktop widths render immediately). + +Every prior guard validates the CONTAINER width (`clientWidth > 0`) and font load +(`waitForTerminalFontMetrics`), but none validate that the RESULTING measured screen/cell width is +non-zero, and a single 0 measurement is cached and never re-validated. `guardAgainstCollapsedTerminalScreen` +closes that gap: while the container has a width but `.xterm-screen` does not, it forces a genuine +CharSizeService remeasure (DOM-strategy `forceTerminalFontRemeasure`) followed by a `fit()`, re-driven by a +`ResizeObserver` so it re-attempts exactly when the mobile modal/keyboard geometry finally settles. It is +bounded (`maxAttempts`) so it never spins, and a no-op once the screen has a non-zero width. +*/ + +/** True when the terminal has a laid-out container but its rendered screen collapsed to zero width. */ +export function isTerminalScreenCollapsed(containerWidth: number, screenWidth: number): boolean { + return containerWidth > 0 && screenWidth <= 0; +} + +/** Handle returned by {@link guardAgainstCollapsedTerminalScreen}; call `dispose()` on teardown. */ +export interface TerminalScreenGuardHandle { + dispose: () => void; +} + +/** + * Watch a mounted xterm terminal for the collapsed-screen state (container has width, `.xterm-screen` + * does not) and force a genuine remeasure + fit until the screen is no longer collapsed or `maxAttempts` + * is exhausted. See the FN-7692 note above for the root cause. Renderer-agnostic and bounded. + * + * @param container The `.xterm` host element (or its wrapper) that also contains `.xterm-screen`. + * @param terminal The xterm Terminal (only its mutable `options.fontFamily` is used, to force remeasure). + * @param fit Runs `FitAddon.fit()` (callers also notify the server of the new cols/rows here). + * @param fontFamily The already-resolved xterm font family to land back on after the remeasure sentinel. + */ +export function guardAgainstCollapsedTerminalScreen( + container: HTMLElement, + terminal: { options: { fontFamily?: string } }, + fit: () => void, + fontFamily: string, + options?: { maxAttempts?: number }, +): TerminalScreenGuardHandle { + const maxAttempts = options?.maxAttempts ?? 40; + let attempts = 0; + let disposed = false; + let rafId: number | null = null; + let observer: ResizeObserver | null = null; + + const containerWidth = (): number => container.getBoundingClientRect().width; + const screenWidth = (): number => { + const screen = container.querySelector(".xterm-screen"); + return screen ? screen.getBoundingClientRect().width : 0; + }; + + const dispose = (): void => { + disposed = true; + if (rafId !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(rafId); + } + rafId = null; + observer?.disconnect(); + observer = null; + }; + + const attemptRecovery = (): void => { + if (disposed) return; + // Healthy — the screen has a real width; stop watching. + if (screenWidth() > 0) { + dispose(); + return; + } + // Not yet measurable — the container itself has no width (mobile modal/keyboard geometry has + // not settled). Do NOT give up: wait for the ResizeObserver to re-drive when it becomes + // measurable. Only a screen that stays collapsed WITH a measurable container is the bug. + if (!isTerminalScreenCollapsed(containerWidth(), screenWidth())) { + return; + } + if (attempts >= maxAttempts) { + dispose(); + return; + } + attempts += 1; + try { + // Force xterm's DOM-based CharSizeService strategy for the remeasure so the cell width is + // measured through the same pipeline the DomRenderer paints through (see FN-7603 note above). + withDomBasedTerminalCharacterMeasurement(() => + forceTerminalFontRemeasure(terminal, fontFamily), + ); + fit(); + } catch { + // Ignore transient fit/measure errors during mobile viewport/keyboard transitions. + } + if (!isTerminalScreenCollapsed(containerWidth(), screenWidth())) { + dispose(); + return; + } + if (typeof requestAnimationFrame === "function") { + rafId = requestAnimationFrame(attemptRecovery); + } + }; + + // Re-drive on container relayout: on mobile the width only becomes measurable after the fullscreen + // modal and keyboard CSS vars settle, which is exactly when a fresh remeasure finally succeeds. + if (typeof ResizeObserver !== "undefined") { + observer = new ResizeObserver(() => attemptRecovery()); + observer.observe(container); + } + // Kick once immediately in case the container is already measurable at mount. + attemptRecovery(); + + return { dispose }; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }