fix(FN-7693): mobile terminal blank — exempt xterm measurement from * { max-width: 100% }
Real root cause of the blank mobile terminal (the FN-7692 remeasure guard did not
fix it and is reverted here). styles.css has a mobile-only reset
`@media (max-width: 768px) { * { max-width: 100% } }` to prevent horizontal
overflow. That universal selector also matches xterm's hidden character-measurement
subtree (`.xterm-helpers` / `.xterm-char-measure-element`). That subtree's containing
block (`.xterm-helpers`) is a 0x0 absolutely-positioned box, so `max-width: 100%`
resolves to `max-width: 0` and hard-caps xterm's character-cell measurement at 0.
FitAddon.fit() then proposes 0 columns/rows and `.xterm-screen` (plus the WebGL
canvas) collapses to 0x0 — the prompt streams in and is written into xterm's row DOM
but paints into a zero-size box, so the terminal is blank. Mobile-only, which is why
desktop always rendered fine.
Reproduced live via mobile emulation: `.xterm-char-measure-element` measured 0 while
an identical monospace span in the same container measured ~295px; `max-width: none`
on the measure element restored ~295px, and reopening the terminal with the exemption
active rendered the prompt with `.xterm-screen` sized 369x760. No amount of
remeasure/refit can fix this — the CSS re-caps the measurement to 0 every time — so
the FN-7692 CharSizeService guard is removed.
- Exempt `.xterm-helpers` / `.xterm-char-measure-element` from the mobile max-width
reset in styles.css (covers both TerminalModal and SessionTerminal)
- Revert the ineffective FN-7692 remeasure guard and its tests
- Update changeset (patch) and the docs/solutions write-up to the real root cause
Note: root cause + fix validated in the automation browser via mobile emulation
(393px, iPhone UA, forced touch), not a physical device.
Fusion-Task-Id: FN-7693
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@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.
|
||||
7
.changeset/fn-7693-mobile-terminal-blank-screen.md
Normal file
7
.changeset/fn-7693-mobile-terminal-blank-screen.md
Normal file
@@ -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: The global mobile `@media (max-width: 768px) { * { max-width: 100% } }` reset in styles.css also matched xterm's hidden character-measurement subtree (`.xterm-helpers` / `.xterm-char-measure-element`). That subtree's containing block is a 0x0 box, so `max-width: 100%` resolved to 0 and hard-capped xterm's character-cell measurement at 0 — FitAddon.fit() then proposed 0 columns and `.xterm-screen` (plus the WebGL canvas) collapsed to 0x0, so the prompt painted into a zero-size box. Exempt xterm's measurement subtree from that reset (`max-width: none`). Mobile-only; desktop was unaffected. Recurrence of FN-7620/FN-7686.
|
||||
@@ -1,74 +1,88 @@
|
||||
---
|
||||
title: "Mobile terminal renders blank because xterm's screen collapses to 0x0"
|
||||
title: "Mobile terminal renders blank: global `* { max-width: 100% }` collapses xterm's char measurement"
|
||||
date: 2026-07-08
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/utils/terminalPreferences.ts
|
||||
module: packages/dashboard/app/styles.css
|
||||
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"
|
||||
- "Mobile terminal is blank after opening; desktop renders fine"
|
||||
- "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
|
||||
- ".xterm-char-measure-element measures 0px width even though an identical monospace span measures ~295px"
|
||||
root_cause: mobile_universal_max_width_100pct_caps_xterm_char_measure_element_at_zero
|
||||
resolution_type: css_exemption
|
||||
severity: high
|
||||
related_components:
|
||||
- packages/dashboard/app/styles.css
|
||||
- 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]
|
||||
tags: [terminal, xterm, mobile, css, max-width, char-size, blank-screen, fn-7620, fn-7686, fn-7693]
|
||||
---
|
||||
|
||||
# Mobile terminal renders blank: xterm screen collapses to 0x0
|
||||
# Mobile terminal renders blank: universal `max-width: 100%` collapses xterm's character measurement
|
||||
|
||||
## 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
|
||||
On the mobile 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.
|
||||
`styles.css` has a global mobile reset to prevent horizontal overflow:
|
||||
|
||||
The bug is:
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
* { max-width: 100%; max-inline-size: 100%; }
|
||||
}
|
||||
```
|
||||
|
||||
- **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.
|
||||
This universal `* { max-width: 100% }` also matches xterm's hidden character-measurement subtree —
|
||||
`.xterm-helpers` and its `.xterm-char-measure-element`. That subtree's containing block
|
||||
(`.xterm-helpers`) is a 0x0 absolutely-positioned box, so `max-width: 100%` resolves to **`max-width:
|
||||
0`** and hard-caps the measurement element at 0 width. xterm's `CharSizeService` therefore reads a
|
||||
0-width character cell, `FitAddon.proposeDimensions()` yields 0 columns/rows, and `.xterm-screen` (plus
|
||||
the WebGL renderer canvas) collapses to **0x0** — the prompt is painted into a zero-size box.
|
||||
|
||||
Reproduced live via mobile emulation: `.xterm-char-measure-element` measured 0 while an identical
|
||||
monospace span in the same `.xterm` container measured ~295px; setting `max-width: none` on the measure
|
||||
element immediately restored ~295px, and reopening the terminal with the exemption active rendered the
|
||||
prompt with `.xterm-screen` sized 369x760.
|
||||
|
||||
The bug is **mobile-only** because the reset is inside a `max-width: 768px` media query — which is
|
||||
exactly why the terminal renders fine at desktop widths.
|
||||
|
||||
### Why prior remeasure-based attempts failed
|
||||
|
||||
FN-7620/FN-7686 (and an initial FN-7693 attempt) tried to force xterm to remeasure/refit after mount.
|
||||
That can never work here: the CSS re-caps the measurement element to 0 width on **every** remeasure, so
|
||||
a resize, a font-size change, and a forced `CharSizeService` remeasure all still read 0. The fix must
|
||||
remove the CSS cap, not re-run the measurement.
|
||||
|
||||
## Fix
|
||||
|
||||
`guardAgainstCollapsedTerminalScreen(container, terminal, fit, fontFamily)` in
|
||||
`app/utils/terminalPreferences.ts`, armed by both TerminalModal and SessionTerminal right after their
|
||||
initial fit:
|
||||
Exempt xterm's measurement subtree from the mobile universal reset (in the same `@media` block in
|
||||
`styles.css`):
|
||||
|
||||
- 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.
|
||||
```css
|
||||
.xterm-helpers,
|
||||
.xterm-helpers *,
|
||||
.xterm-char-measure-element {
|
||||
max-width: none !important;
|
||||
max-inline-size: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
`.xterm-helpers` is xterm's own class, so this covers both terminal surfaces (TerminalModal and
|
||||
SessionTerminal) without per-component changes.
|
||||
|
||||
## 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
|
||||
- Live: with the exemption active, `.xterm-char-measure-element` measures ~295px (was 0) and
|
||||
`.xterm-screen` gets a real width (was 0x0), rendering the prompt on the mobile viewport.
|
||||
- Physical-device note: root cause and fix were reproduced/validated in the automation browser via
|
||||
mobile emulation (393px, iPhone UA, forced touch), not a physical iPhone. Confirm on a real device
|
||||
when possible.
|
||||
|
||||
@@ -11,13 +11,11 @@ import { isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
|
||||
import {
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
forceTerminalFontRemeasure,
|
||||
guardAgainstCollapsedTerminalScreen,
|
||||
readTerminalPreferences,
|
||||
resolveTerminalFontFamily,
|
||||
resolveTerminalGlyphFontFamily,
|
||||
waitForTerminalFontMetrics,
|
||||
withDomBasedTerminalCharacterMeasurement,
|
||||
type TerminalScreenGuardHandle,
|
||||
} from "../utils/terminalPreferences";
|
||||
|
||||
/**
|
||||
@@ -194,8 +192,6 @@ export function SessionTerminal({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<ITerminalAddon | null>(null);
|
||||
/** FN-7692: active collapsed-screen guard for the current xterm; disposed on teardown. */
|
||||
const screenGuardRef = useRef<TerminalScreenGuardHandle | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const [postureTooltipOpen, setPostureTooltipOpen] = useState(false);
|
||||
@@ -512,28 +508,6 @@ 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,
|
||||
@@ -710,8 +684,6 @@ 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 {
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
TERMINAL_FONT_FAMILY_PRESETS,
|
||||
clampTerminalFontSize,
|
||||
forceTerminalFontRemeasure,
|
||||
guardAgainstCollapsedTerminalScreen,
|
||||
readTerminalPreferences,
|
||||
resolveTerminalFontFamily,
|
||||
resolveTerminalGlyphFontFamily,
|
||||
@@ -51,7 +50,6 @@ import {
|
||||
writeTerminalPreferences,
|
||||
type TerminalPreferences,
|
||||
type TerminalRenderer,
|
||||
type TerminalScreenGuardHandle,
|
||||
} from "../utils/terminalPreferences";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
@@ -608,12 +606,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
const initializedRendererRef = useRef<TerminalRenderer>(terminalPreferences.renderer);
|
||||
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
|
||||
const pendingFitRef = useRef<number | null>(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<TerminalScreenGuardHandle | null>(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.
|
||||
@@ -1396,8 +1388,6 @@ 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) {
|
||||
@@ -1568,28 +1558,6 @@ 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.
|
||||
@@ -1735,8 +1703,6 @@ 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) {
|
||||
@@ -2123,8 +2089,6 @@ 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) {
|
||||
@@ -2190,8 +2154,6 @@ 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) {
|
||||
|
||||
@@ -3484,6 +3484,29 @@ Toast text must contrast its status background across every dashboard theme and
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-08-17:10:
|
||||
FN-7693 (real root cause of the mobile blank terminal — supersedes the FN-7620/FN-7686 remeasure
|
||||
attempts): the universal `* { max-width: 100% }` reset above ALSO matches xterm's hidden character-
|
||||
measurement subtree (`.xterm-helpers` and its `.xterm-char-measure-element`). That subtree's containing
|
||||
block (`.xterm-helpers`) is a 0x0 absolutely-positioned box, so `max-width: 100%` resolves to
|
||||
`max-width: 0` and hard-caps xterm's character-cell measurement at 0. FitAddon.fit() then proposes 0
|
||||
columns/rows and `.xterm-screen` (plus the WebGL renderer canvas) collapses to 0x0 — the shell prompt
|
||||
DOES stream in and IS written into xterm's row DOM, but paints into a zero-size box, so the terminal is
|
||||
blank. This bites ONLY on mobile because the reset is mobile-scoped (this @media block), which is why the
|
||||
terminal renders fine at desktop widths. Exempt xterm's measurement subtree so the cell width measures
|
||||
its true content width. Verified live via mobile emulation: with this exemption
|
||||
`.xterm-char-measure-element` measures ~295px (was 0) and `.xterm-screen` gets a real width (was 0x0),
|
||||
rendering the prompt. Applies to both terminal surfaces (TerminalModal + SessionTerminal) since
|
||||
`.xterm-helpers` is xterm's own class.
|
||||
*/
|
||||
.xterm-helpers,
|
||||
.xterm-helpers *,
|
||||
.xterm-char-measure-element {
|
||||
max-width: none !important;
|
||||
max-inline-size: none !important;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
.code-block {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { 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,
|
||||
@@ -171,138 +169,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,127 +294,6 @@ export function withDomBasedTerminalCharacterMeasurement<T>(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<HTMLElement>(".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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user