From 8d73b18c359925eb0a3c4842449133948fcd9515 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 6 Jul 2026 19:35:27 -0700 Subject: [PATCH] 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) --- .../fn-7620-mobile-terminal-blank-render.md | 7 + ...al-blank-render-zero-geometry-container.md | 112 ++++++ .../app/components/TerminalModal.tsx | 49 +++ .../__tests__/SessionTerminal.test.tsx | 66 ++++ .../__tests__/TerminalModal.test.tsx | 358 ++++++++++++++++++ 5 files changed, 592 insertions(+) create mode 100644 .changeset/fn-7620-mobile-terminal-blank-render.md create mode 100644 docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md diff --git a/.changeset/fn-7620-mobile-terminal-blank-render.md b/.changeset/fn-7620-mobile-terminal-blank-render.md new file mode 100644 index 0000000000..350be25920 --- /dev/null +++ b/.changeset/fn-7620-mobile-terminal-blank-render.md @@ -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. diff --git a/docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md b/docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md new file mode 100644 index 0000000000..a8feffeac3 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md @@ -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`. diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 041fb2f2cc..afd41657a7 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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, diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx index e038937403..7c90b91ee9 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -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(); + 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()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 14ca8230e2..e65560e61a 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -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(); + 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(); + 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(); + 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(); + + 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(); + 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))); + }); + }); +});