fix(FN-7692): recover blank mobile terminal when xterm screen collapses to 0x0

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-08 13:22:10 -07:00
parent 461a4a2711
commit b7b1b71cae
6 changed files with 405 additions and 1 deletions

View File

@@ -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.