FN-7620: fix mobile terminal rendering blank via ResizeObserver recovery
Fixes the mobile dashboard terminal sometimes rendering completely blank on open by having TerminalModal recover from a zero/collapsed container box. - TerminalModal now attaches a persistent ResizeObserver directly on the xterm container, mirroring SessionTerminal's existing pattern - When the container reports a zero/collapsed box on the first post-open fit, it now re-fits once the real box settles instead of staying stuck at FitAddon's degenerate 2x1-cell floor - Added regression tests covering TerminalModal and SessionTerminal zero-geometry recovery - Documented the root cause and fix in docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md - Added a patch changeset for @runfusion/fusion Files changed: .changeset/fn-7620-mobile-terminal-blank-render.md | 7 + docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md | 112 +++++++ packages/dashboard/app/components/TerminalModal.tsx | 49 +++ packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx | 66 ++++ packages/dashboard/app/components/__tests__/TerminalModal.test.tsx | 358 +++++++++++++++++++++ 5 files changed, 592 insertions(+) Fusion-Task-Id: FN-7620 Fusion-Task-Lineage: 103f5b17-9a6e-4e9a-ab61-65ccb2203a8d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7620-mobile-terminal-blank-render.md
Normal file
7
.changeset/fn-7620-mobile-terminal-blank-render.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix mobile dashboard terminal sometimes rendering completely blank on open.
|
||||
category: fix
|
||||
dev: TerminalModal now attaches a persistent ResizeObserver directly on the xterm container (mirroring SessionTerminal's existing pattern), so a container that reports a zero/collapsed box at the first post-open fit recovers as soon as its real box settles, instead of staying at FitAddon's degenerate 2x1-cell floor forever.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: "Mobile terminal renders nothing when the xterm container's own box is unwatched"
|
||||
date: 2026-07-06
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/components/TerminalModal
|
||||
problem_type: ui_bug
|
||||
component: frontend_terminal
|
||||
applies_when: "A terminal/xterm surface is opened inside a container whose real box may be zero (or otherwise not yet settled) at the moment `terminal.open()`/`fitAddon.fit()` first runs, and no observer watches that CONTAINER element's own geometry independently of any ancestor."
|
||||
symptoms:
|
||||
- "The mobile terminal opens to a totally blank surface: no prompt, no output, no rows — a different, more severe failure than visible-but-mis-spaced ASCII."
|
||||
- "The blank render happens on the INITIAL layout (keyboard open or closed) and does not repair itself from a keyboard toggle, orientation change, reconnect, or manual refit — because none of those events touch the terminal container's own box."
|
||||
- "Desktop/docked/floating terminals are unaffected; only the mobile fullscreen shell (or any narrow/late-settling container) shows the blank symptom."
|
||||
root_cause: real_fitaddon_proposedimensions_floors_to_a_degenerate_2x1_grid_on_a_zero_container_box_and_nothing_watched_the_container_itself_to_recover
|
||||
resolution_type: code_fix
|
||||
severity: high
|
||||
related_components:
|
||||
- packages/dashboard/app/components/TerminalModal.tsx
|
||||
- packages/dashboard/app/components/SessionTerminal.tsx
|
||||
- packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
|
||||
- packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx
|
||||
- FN-7620
|
||||
tags:
|
||||
- xterm
|
||||
- fitaddon
|
||||
- resize-observer
|
||||
- mobile-safari
|
||||
- blank-render
|
||||
- geometry
|
||||
---
|
||||
|
||||
# Mobile terminal renders nothing when the xterm container's own box is unwatched
|
||||
|
||||
## Problem
|
||||
|
||||
FN-7620 reported the mobile dashboard terminal opening to a completely blank surface — no prompt, no scrollback, no rows. This is a DIFFERENT failure class than the FN-7456→FN-7603 inter-character-spacing family (`xterm-options-noop-remeasure-after-font-settle.md`), which always rendered visible-but-mis-spaced ASCII. Here nothing renders at all.
|
||||
|
||||
### The actual mechanism
|
||||
|
||||
Real `@xterm/addon-fit@0.10.0`'s `FitAddon.proposeDimensions()` reads `getComputedStyle(terminal.element.parentElement)` height/width (the xterm container, i.e. `TerminalModal`'s `terminalRef` div):
|
||||
|
||||
```js
|
||||
proposeDimensions() {
|
||||
...
|
||||
const o = parseInt(computedStyle(parentElement).height);
|
||||
const s = Math.max(0, parseInt(computedStyle(parentElement).width));
|
||||
...
|
||||
return {
|
||||
cols: Math.max(2, Math.floor(availableWidth / cellWidth)),
|
||||
rows: Math.max(1, Math.floor(availableHeight / cellHeight)),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Crucially, this does **not** bail out on a zero box — it floors to a degenerate `{cols: 2, rows: 1}` grid. If the container's real box is genuinely `0` at the moment of the first post-open `fitAddon.fit()` (plausible on a real mobile device on the very first paint — the mobile fullscreen `.terminal-modal--mobile` + `[style*="--keyboard-overlap"]` height cascade, `100dvh` support, or general layout/font settle can all delay the container's real box by a frame or more), xterm silently resizes to a near-invisible 2×1 grid clipped inside a container whose own box is *also* still ~0px — visually indistinguishable from "nothing renders".
|
||||
|
||||
The defect that made this **permanent** (not a one-frame flicker): only the OUTER MODAL (`modalRef`) had a `ResizeObserver`. A modal already sized to `100dvh`/the keyboard-constrained box does not re-fire that observer when only *inner* content (the xterm container) later settles to its real size — nothing else watched the container (`terminalRef`) itself. The prior "one opportunistic deferred re-fit" (added 2026-06-22 for the FN-7461 fold-transition spacing fix) only recovers if `clientWidth > 0` at the SINGLE scheduled rAF check; if the container is still zero at that moment, no later trigger ever re-measures it, and the terminal stays a degenerate grid forever — matching the report that no reconnect/orientation/keyboard-toggle/manual refit repaired it.
|
||||
|
||||
## Why this survived FN-7456→FN-7603
|
||||
|
||||
Every prior fix in this subsystem targeted the CHARACTER-measurement pipeline (font metrics, DOM-vs-Canvas divergence) — none of them touched CONTAINER-level geometry observation. A total blank render is a fundamentally different failure surface (container box, not glyph metrics) and none of the prior regressions modeled a zero/degenerate container box at all.
|
||||
|
||||
`SessionTerminal.tsx` (the sibling embedded CLI-agent terminal) never had this defect: it already attaches `resizeObserver.observe(containerRef.current)` directly on its own xterm container right after init.
|
||||
|
||||
## Fix
|
||||
|
||||
Add a persistent `ResizeObserver` directly on the xterm CONTAINER element (`terminalRef.current`), established for the life of each xterm instance and re-established whenever the container remounts (tab switch uses `key={activeTab?.sessionId}` on the container div), calling the existing `fitAndResizeForSession(sessionId)` on any change to the container's OWN box:
|
||||
|
||||
```tsx
|
||||
// packages/dashboard/app/components/TerminalModal.tsx
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const node = terminalRef.current;
|
||||
if (!node || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
let pendingFrame: number | null = null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame);
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null;
|
||||
const sessionId =
|
||||
typeof xtermInitializedRef.current === "string" ? xtermInitializedRef.current : undefined;
|
||||
fitAndResizeForSession(sessionId);
|
||||
});
|
||||
});
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame);
|
||||
};
|
||||
}, [fitAndResizeForSession, isOpen, activeTab?.sessionId]);
|
||||
```
|
||||
|
||||
This mirrors the pattern `SessionTerminal.tsx` already uses. Per the ResizeObserver spec, an initial notification fires shortly after `observe()` is called even if the box hasn't changed since — so this also catches the very first zero-to-real transition, not just later changes. No arbitrary frame-polling loop, timeout, hardcoded cell/column count, or disabling of the mobile fullscreen path is used.
|
||||
|
||||
Do not:
|
||||
|
||||
- Replace this with a fixed cell/column count or an arbitrary mobile-only min-height — that masks the symptom instead of fixing the missing observation.
|
||||
- Assume the outer modal's `ResizeObserver` is sufficient — it is not, because the modal's own box can stay constant (`100dvh`) while only the container's box changes.
|
||||
- Gate this fix to mobile only — it is a general container-geometry gap; desktop/docked/floating benefit from the same additive fix with no observed regression.
|
||||
|
||||
## Regression coverage (rendered geometry, not init/fit call presence)
|
||||
|
||||
jsdom cannot exercise real CSS layout, so the regression models the exact mechanism directly:
|
||||
|
||||
- Override the xterm container's (`data-testid="terminal-xterm"`) `clientWidth`/`clientHeight` getters to report `0` at the moment of the first post-open fit, then flip them to a real, stable nonzero box a moment later — with NO reconnect/orientation/keyboard-toggle/manual-refit call.
|
||||
- Swap the shared `FitAddon.fit()` mock's implementation (scoped to this describe block only) for a variant that mirrors real `proposeDimensions()`'s degenerate-floor formula (`Math.max(2, floor(width/cellWidth))` / `Math.max(1, floor(height/cellHeight))`) against the REAL container element, instead of the fixed-width mock the spacing-family tests use.
|
||||
- Capture every `new ResizeObserver(cb)` instance/target via a `MockResizeObserver`, then fire the SAME notification a real browser delivers for the container element specifically — this is the decisive step: pre-fix, no ResizeObserver is ever attached to `terminal-xterm` (only the modal), so this assertion fails outright; post-fix it recovers to a real, non-degenerate grid.
|
||||
- Covers keyboard-CLOSED and keyboard-OPEN initial layouts, tab-switch remount (the container's `key={sessionId}` swap must re-target the new node), and duplicate/rapid resize notification coalescing.
|
||||
- `SessionTerminal.tsx` gets a parallel regression proving it already has (and keeps) this same container-level observer.
|
||||
- Run: `pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/TerminalModal.test.tsx app/components/__tests__/SessionTerminal.test.tsx app/components/__tests__/SessionTerminal.mobile.test.tsx --silent=passed-only --reporter=dot`.
|
||||
- Real mobile Safari/Chrome sanity check remains the strongest signal for this class of bug; a real-device screenshot was not obtainable in this execution environment (headless coding agent, no physical device access) — this gap is recorded explicitly (task document key="repro" on FN-7620) rather than treating jsdom/desktop WebKit as proof. See `docs/ios-acceptance.md`.
|
||||
@@ -1038,6 +1038,55 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
replaceActiveTabSession,
|
||||
} = useTerminalSessions(projectId);
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-06-09:15:
|
||||
FN-7620 root cause: the mobile terminal could render BLANK (not merely
|
||||
mis-spaced) because nothing ever watched the xterm CONTAINER's (`terminalRef`)
|
||||
own box. Real `FitAddon.proposeDimensions()` (@xterm/addon-fit@0.10.0) reads
|
||||
`getComputedStyle(terminal.element.parentElement)` height/width and, when that
|
||||
resolves to 0 (e.g. the mobile fullscreen/keyboard-overlap height cascade,
|
||||
dvh support, or web-font/layout settle has not finished by the time the first
|
||||
`fitAddon.fit()` call in `initTerminal` runs), floors to a degenerate
|
||||
`{cols: 2, rows: 1}` grid rather than bailing out — xterm silently resizes
|
||||
into a near-invisible box. Only `modalRef` (the whole modal) had a
|
||||
ResizeObserver; a modal that is already sized to 100dvh/the keyboard box does
|
||||
not re-fire that observer when only INNER content (the terminal container)
|
||||
later settles to its real size, so the degenerate grid could persist forever
|
||||
with no reconnect/orientation/keyboard-toggle/manual-refit path able to catch
|
||||
it. `SessionTerminal.tsx` already observes its own container this way (see
|
||||
its `resizeObserver.observe(containerRef.current)`); TerminalModal did not.
|
||||
Mirror that: observe the xterm container itself for the life of each xterm
|
||||
instance so ANY change in its OWN box (not just the outer modal's box) —
|
||||
including the very first zero-to-real transition — triggers a corrective
|
||||
fit via the existing `fitAndResizeForSession`. Re-established whenever the
|
||||
container remounts (tab switch uses `key={activeTab?.sessionId}` on the
|
||||
container div). See docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const node = terminalRef.current;
|
||||
if (!node || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
let pendingFrame: number | null = null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame);
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null;
|
||||
const sessionId =
|
||||
typeof xtermInitializedRef.current === "string"
|
||||
? xtermInitializedRef.current
|
||||
: undefined;
|
||||
fitAndResizeForSession(sessionId);
|
||||
});
|
||||
});
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame);
|
||||
};
|
||||
}, [fitAndResizeForSession, isOpen, activeTab?.sessionId]);
|
||||
|
||||
const {
|
||||
projectName: terminalWorkspaceProjectName,
|
||||
workspaces: terminalWorkspaces,
|
||||
|
||||
@@ -776,3 +776,69 @@ describe("SessionTerminal", () => {
|
||||
await waitFor(() => expect(screen.queryByText("Not yet")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-06-09:20:
|
||||
FN-7620 investigated whether `SessionTerminal` (the embedded CLI-agent
|
||||
terminal) shares `TerminalModal`'s mobile blank-render defect (a zero-geometry
|
||||
xterm container with no recovery path once collapsed to FitAddon's degenerate
|
||||
{cols:2, rows:1} floor). It does NOT: `SessionTerminal` already attaches
|
||||
`resizeObserver.observe(containerRef.current)` directly on its own xterm
|
||||
container (`cli-terminal-viewport`) right after init — the SAME container-level
|
||||
observer pattern `TerminalModal` was missing and this task added. This proves
|
||||
that invariant with the same "fire the exact ResizeObserver notification a real
|
||||
browser delivers, without any reconnect/keyboard-toggle/orientation trigger"
|
||||
model used in `TerminalModal.test.tsx`'s FN-7620 coverage.
|
||||
*/
|
||||
describe("SessionTerminal — FN-7620 mobile blank render (container geometry recovery, unaffected surface)", () => {
|
||||
type CapturedEntry = { target: Element; callback: ResizeObserverCallback };
|
||||
let captured: CapturedEntry[] = [];
|
||||
let originalGlobalResizeObserver: unknown;
|
||||
|
||||
class CapturingResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe(target: Element): void {
|
||||
captured.push({ target, callback: this.callback });
|
||||
}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
originalGlobalResizeObserver = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = CapturingResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = originalGlobalResizeObserver;
|
||||
});
|
||||
|
||||
it("observes its own xterm container (not just relying on an outer/ancestor observer) so a zero-then-real geometry transition recovers without any external trigger", async () => {
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
await waitFor(() => expect(mockTerm.open).toHaveBeenCalled());
|
||||
|
||||
const container = screen.getByTestId("cli-terminal-viewport");
|
||||
const matches = captured.filter((entry) => entry.target === container);
|
||||
|
||||
// Decisive invariant: SessionTerminal already wires a ResizeObserver
|
||||
// directly onto its own xterm container element (unlike pre-fix
|
||||
// TerminalModal, which only observed the outer modal box).
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
|
||||
mockFitAddon.fit.mockClear();
|
||||
// Fire the same notification a real browser delivers the instant the
|
||||
// container's box changes — not a manual fit()/reconnect call.
|
||||
act(() => {
|
||||
for (const entry of matches) {
|
||||
entry.callback([] as unknown as ResizeObserverEntry[], entry as unknown as ResizeObserver);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockFitAddon.fit).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7988,3 +7988,361 @@ describe("TerminalModal — FN-7603 mobile inter-character spacing (Canvas vs DO
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-06-09:15:
|
||||
FN-7620: "terminal renders nothing on mobile" is a DIFFERENT symptom than the
|
||||
FN-7456->FN-7603 inter-character-spacing family above — a total blank render,
|
||||
not visible-but-spaced ASCII. Root cause: real `FitAddon.proposeDimensions()`
|
||||
(@xterm/addon-fit@0.10.0) reads `getComputedStyle(terminal.element.
|
||||
parentElement)` height/width and floors to a degenerate `{cols: 2, rows: 1}`
|
||||
grid — rather than bailing out — whenever that resolves to a zero box (e.g.
|
||||
the mobile fullscreen/keyboard-overlap CSS height cascade has not settled by
|
||||
the time the first post-open `fitAddon.fit()` runs). Only the WHOLE MODAL
|
||||
(`modalRef`) had a ResizeObserver; nothing watched the xterm CONTAINER
|
||||
(`terminalRef`) itself, so if the container's OWN box was still zero at the
|
||||
single scheduled deferred-fit check, no later trigger ever re-measured it —
|
||||
the terminal stayed a near-invisible 2x1 grid clipped inside a genuinely
|
||||
zero-height box. This models that exact mechanism: `mockFitAddonFit`'s
|
||||
implementation is temporarily swapped for a variant that reads the REAL
|
||||
container's clientWidth/clientHeight (mirroring the real addon's
|
||||
`getComputedStyle(...).width/height` read) instead of the shared fixed-width
|
||||
constant the spacing-family tests above use, and a captured
|
||||
`new ResizeObserver(cb)` lets the test fire the SAME notification a real
|
||||
browser would deliver the instant the container's box changes size — without
|
||||
calling any keyboard-toggle/orientation/reconnect/manual-refit production
|
||||
handler. See docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md.
|
||||
*/
|
||||
describe("TerminalModal — FN-7620 mobile blank render (zero-geometry xterm container)", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockSendInput = vi.fn();
|
||||
const mockResize = vi.fn();
|
||||
const mockReconnect = vi.fn();
|
||||
|
||||
const createMockTerminalState = (overrides = {}) => ({
|
||||
connectionStatus: "connected" as const,
|
||||
sendInput: mockSendInput,
|
||||
resize: mockResize,
|
||||
onData: vi.fn(() => vi.fn()),
|
||||
onExit: vi.fn(() => vi.fn()),
|
||||
onConnect: vi.fn(() => vi.fn()),
|
||||
onScrollback: vi.fn(() => vi.fn()),
|
||||
reconnect: mockReconnect,
|
||||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type CapturedResizeObserverEntry = { target: Element; callback: ResizeObserverCallback };
|
||||
let capturedResizeObservers: CapturedResizeObserverEntry[] = [];
|
||||
|
||||
class MockResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe(target: Element): void {
|
||||
capturedResizeObservers.push({ target, callback: this.callback });
|
||||
}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires the captured ResizeObserver callback(s) for the element with the
|
||||
* given `data-testid`, mirroring exactly what a real browser delivers when
|
||||
* that element's own box changes size. Returns false if no ResizeObserver
|
||||
* ever observed that element (the pre-fix state for "terminal-xterm").
|
||||
*/
|
||||
function fireResizeObserverFor(testId: string): boolean {
|
||||
const target = screen.getByTestId(testId);
|
||||
const matches = capturedResizeObservers.filter((entry) => entry.target === target);
|
||||
if (matches.length === 0) return false;
|
||||
for (const entry of matches) {
|
||||
entry.callback(
|
||||
[] as unknown as ResizeObserverEntry[],
|
||||
entry as unknown as ResizeObserver,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mirrors real FitAddon.proposeDimensions()'s degenerate-floor formula
|
||||
// (`Math.max(2, floor(width/cellWidth))` / `Math.max(1, floor(height/
|
||||
// cellHeight))`) against the REAL xterm container element's clientWidth/
|
||||
// clientHeight — unlike the shared fixed-width mock the spacing-family
|
||||
// tests above use, this is what lets the test distinguish "the container is
|
||||
// still a zero/degenerate box" from "the container has settled to a real,
|
||||
// usable box".
|
||||
const CHAR_WIDTH_PX = 9;
|
||||
const CHAR_HEIGHT_PX = 17;
|
||||
const geometryAwareFitAddonFit = vi.fn(() => {
|
||||
const container = screen.queryByTestId("terminal-xterm");
|
||||
const width = container?.clientWidth ?? 0;
|
||||
const height = container?.clientHeight ?? 0;
|
||||
mockTerminalInstance.cols = Math.max(2, Math.floor(width / CHAR_WIDTH_PX));
|
||||
mockTerminalInstance.rows = Math.max(1, Math.floor(height / CHAR_HEIGHT_PX));
|
||||
});
|
||||
|
||||
const originalMockFitAddonFitImpl = mockFitAddonFit.getMockImplementation();
|
||||
let previousInnerWidth: number;
|
||||
let previousOntouchstart: unknown;
|
||||
let originalWindowResizeObserver: unknown;
|
||||
let originalGlobalResizeObserver: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetMockTerminalGeometry();
|
||||
resetFontRemeasureCount();
|
||||
capturedResizeObservers = [];
|
||||
mockFitAddonFit.mockImplementation(geometryAwareFitAddonFit);
|
||||
|
||||
originalWindowResizeObserver = (window as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||||
originalGlobalResizeObserver = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||||
(window as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver;
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver;
|
||||
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
previousInnerWidth = window.innerWidth;
|
||||
previousOntouchstart = window.ontouchstart;
|
||||
// Real reported device: a narrow touch-primary mobile viewport.
|
||||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||||
|
||||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||||
mockTerminalInstance.options.fontFamily = XTERM_FONT_FAMILY;
|
||||
mockTerminalInstance.options.fontSize = 14;
|
||||
mockTerminalInstance.cols = 80;
|
||||
mockTerminalInstance.rows = 24;
|
||||
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||||
mockUseWorkspaces.mockReturnValue({
|
||||
projectName: "kb",
|
||||
workspaces: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
mockCreateTerminalSession.mockResolvedValue({
|
||||
sessionId: "test-session-123",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/project",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||||
if (previousOntouchstart === undefined) {
|
||||
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
|
||||
} else {
|
||||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||||
}
|
||||
if (originalWindowResizeObserver) {
|
||||
(window as unknown as { ResizeObserver: unknown }).ResizeObserver = originalWindowResizeObserver;
|
||||
} else {
|
||||
delete (window as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||||
}
|
||||
if (originalGlobalResizeObserver) {
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = originalGlobalResizeObserver;
|
||||
} else {
|
||||
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||||
}
|
||||
mockFitAddonFit.mockImplementation(originalMockFitAddonFitImpl ?? (() => {}));
|
||||
Object.defineProperty(window, "visualViewport", { value: undefined, writable: true, configurable: true });
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function overrideContainerBox(container: HTMLElement, box: { width: number; height: number }): void {
|
||||
Object.defineProperty(container, "clientWidth", { configurable: true, get: () => box.width });
|
||||
Object.defineProperty(container, "clientHeight", { configurable: true, get: () => box.height });
|
||||
}
|
||||
|
||||
it("recovers from a zero-geometry xterm container on the INITIAL mobile layout with the keyboard CLOSED — no reconnect/orientation/refit", async () => {
|
||||
let capturedDataCallback: ((data: string) => void) | null = null;
|
||||
let capturedScrollbackCallback: ((data: string) => void) | null = null;
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
capturedDataCallback = cb;
|
||||
return vi.fn();
|
||||
}),
|
||||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||||
capturedScrollbackCallback = cb;
|
||||
return vi.fn();
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
const container = screen.getByTestId("terminal-xterm");
|
||||
const box = { width: 0, height: 0 };
|
||||
overrideContainerBox(container, box);
|
||||
|
||||
// Let the initial fit()+deferred-refit sequence run against a genuinely
|
||||
// zero container box — the real-device race this task fixes.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Pre-condition: with the container still reporting 0x0, xterm has
|
||||
// collapsed to FitAddon's degenerate floor — exactly the "nothing
|
||||
// renders" mechanism (a 2x1 grid clipped inside a 0px box).
|
||||
expect(mockTerminalInstance.cols).toBe(2);
|
||||
expect(mockTerminalInstance.rows).toBe(1);
|
||||
|
||||
// The real device settles the container to its actual, stable box a
|
||||
// moment later — no user action, CSS/layout/font settle only.
|
||||
box.width = 360;
|
||||
box.height = 480;
|
||||
|
||||
// Decisive step: fire the SAME notification a real browser's
|
||||
// ResizeObserver would deliver for THIS container the instant its box
|
||||
// changed — not a manual fitAddon.fit()/reconnect/keyboard-toggle call.
|
||||
// Pre-fix, no ResizeObserver ever observes this container (only the
|
||||
// whole modal), so this assertion fails outright.
|
||||
const observed = fireResizeObserverFor("terminal-xterm");
|
||||
expect(observed).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.cols).toBeGreaterThan(2);
|
||||
expect(mockTerminalInstance.rows).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// Rendered-state invariant: a non-zero, stable measured box AND real
|
||||
// (non-degenerate) rows/cols — not merely that fit()/open() was
|
||||
// attempted.
|
||||
expect(container.clientWidth).toBe(360);
|
||||
expect(container.clientHeight).toBe(480);
|
||||
expect(mockTerminalInstance.cols).toBe(Math.max(2, Math.floor(360 / CHAR_WIDTH_PX)));
|
||||
expect(mockTerminalInstance.rows).toBe(Math.max(1, Math.floor(480 / CHAR_HEIGHT_PX)));
|
||||
|
||||
// Output/prompt actually renders (write() receives real data), not just
|
||||
// an init attempt.
|
||||
mockTerminalInstance.write.mockClear();
|
||||
act(() => {
|
||||
capturedScrollbackCallback?.("user@host:~$ ");
|
||||
capturedDataCallback?.("ls\r\n");
|
||||
});
|
||||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("user@host:~$ ");
|
||||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls\r\n");
|
||||
});
|
||||
|
||||
it("recovers from a zero-geometry xterm container on the INITIAL mobile layout with the keyboard already OPEN", async () => {
|
||||
const mockVV = {
|
||||
width: 360,
|
||||
height: 300,
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "visualViewport", { value: mockVV, writable: true, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
const container = screen.getByTestId("terminal-xterm");
|
||||
const box = { width: 0, height: 0 };
|
||||
overrideContainerBox(container, box);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockTerminalInstance.cols).toBe(2);
|
||||
expect(mockTerminalInstance.rows).toBe(1);
|
||||
|
||||
box.width = 360;
|
||||
box.height = 260;
|
||||
|
||||
const observed = fireResizeObserverFor("terminal-xterm");
|
||||
expect(observed).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.cols).toBeGreaterThan(2);
|
||||
expect(mockTerminalInstance.rows).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
expect(container.clientWidth).toBe(360);
|
||||
expect(container.clientHeight).toBe(260);
|
||||
expect(mockTerminalInstance.cols).toBe(Math.max(2, Math.floor(360 / CHAR_WIDTH_PX)));
|
||||
expect(mockTerminalInstance.rows).toBe(Math.max(1, Math.floor(260 / CHAR_HEIGHT_PX)));
|
||||
});
|
||||
|
||||
it("re-establishes the container ResizeObserver against the NEW container node after a tab-switch remount", async () => {
|
||||
const secondTab = {
|
||||
id: "tab-2",
|
||||
sessionId: "session-2",
|
||||
title: "Terminal 2",
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalledTimes(1));
|
||||
|
||||
const firstContainer = screen.getByTestId("terminal-xterm");
|
||||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||||
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [{ ...defaultTab, isActive: false }, secondTab],
|
||||
activeTab: secondTab,
|
||||
});
|
||||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalledTimes(2));
|
||||
|
||||
const secondContainer = screen.getByTestId("terminal-xterm");
|
||||
expect(secondContainer).not.toBe(firstContainer);
|
||||
// The container remounted with a new sessionId key — the observer must
|
||||
// now target the NEW node, not the stale/unmounted one.
|
||||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||||
});
|
||||
|
||||
it("coalesces duplicate/rapid container resize notifications into a single settled fit", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
const container = screen.getByTestId("terminal-xterm");
|
||||
|
||||
const box = { width: 0, height: 0 };
|
||||
overrideContainerBox(container, box);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockTerminalInstance.cols).toBe(2);
|
||||
expect(mockTerminalInstance.rows).toBe(1);
|
||||
|
||||
box.width = 640;
|
||||
box.height = 400;
|
||||
|
||||
// Two rapid duplicate notifications (e.g. layout + font settle firing in
|
||||
// the same tick) must coalesce to one corrective fit, not double-apply.
|
||||
mockFitAddonFit.mockClear();
|
||||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.cols).toBe(Math.max(2, Math.floor(640 / CHAR_WIDTH_PX)));
|
||||
expect(mockTerminalInstance.rows).toBe(Math.max(1, Math.floor(400 / CHAR_HEIGHT_PX)));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user