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:
7
.changeset/fn-7692-mobile-terminal-blank-screen.md
Normal file
7
.changeset/fn-7692-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: 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.
|
||||
@@ -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.
|
||||
@@ -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<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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,6 +294,127 @@ 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