fix(FN-1043): fix terminal mobile keyboard layout

- Defer fitAddon.fit() via requestAnimationFrame after CSS repaint to ensure container dimensions are settled
- Add overflow:hidden to keyboard-open CSS rule to prevent layout shift
- Add regression tests for xterm re-fit on mobile keyboard open/close
- Update terminal mobile keyboard documentation in README files
This commit is contained in:
gsxdsm
2026-04-06 21:35:46 -07:00
parent a119798dc9
commit dd409094fe
6 changed files with 193 additions and 13 deletions

View File

@@ -80,6 +80,11 @@ describe("terminal mobile keyboard layout CSS contract", () => {
expect(ruleBody).toContain(`max-height: ${viewportExpression}`);
});
it("keyboard-open selector includes overflow: hidden to clip content during keyboard transition", () => {
const ruleBody = findKeyboardOpenRule();
expect(ruleBody).toContain("overflow: hidden");
});
it("height and max-height use the identical expression", () => {
const ruleBody = findKeyboardOpenRule();

View File

@@ -101,6 +101,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
const hasInitialCommandRun = useRef<string | false>(false);
const xtermInitializedRef = useRef<string | false>(false);
const resizeRef = useRef<((cols: number, rows: number) => void) | null>(null);
/** Tracks a pending requestAnimationFrame for deferred xterm re-fit. */
const pendingFitRef = useRef<number | null>(null);
// Bump open generation whenever the modal opens so the initialCommand
// effect re-evaluates after a close/reopen cycle (deps may be identical).
@@ -133,18 +135,43 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
// Re-fit xterm when viewport changes affect available height.
// The keyboard opening/closing changes the modal's max-height via
// CSS --keyboard-overlap, so xterm needs to recalculate rows/cols.
if (fitAddonRef.current && xtermRef.current) {
try {
const fitAddon = fitAddonRef.current as InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
fitAddon.fit();
const { cols, rows } = xtermRef.current;
if (resizeRef.current) {
resizeRef.current(cols, rows);
}
} catch {
// Ignore fit errors during viewport transitions
}
//
// IMPORTANT: We must defer fitAddon.fit() until AFTER React has
// committed the state changes above (setKeyboardOverlap, setViewportHeight)
// and the browser has repainted the new modal dimensions. Without this
// deferral, fit() measures the OLD (pre-keyboard) container dimensions
// because React state updates are asynchronous — the inline style with
// the new --keyboard-overlap / --vv-height values hasn't been applied yet.
//
// requestAnimationFrame ensures we run after the next paint, at which
// point the DOM reflects the updated CSS variables and the modal has
// its correct constrained height.
//
// Coalesce rapid events (keyboard animating open) by cancelling any
// previously scheduled rAF before scheduling a new one.
if (pendingFitRef.current !== null) {
cancelAnimationFrame(pendingFitRef.current);
pendingFitRef.current = null;
}
pendingFitRef.current = requestAnimationFrame(() => {
pendingFitRef.current = null;
// Read refs inside the callback to avoid stale closures
const currentFitAddon = fitAddonRef.current;
const currentXterm = xtermRef.current;
const currentResize = resizeRef.current;
if (currentFitAddon && currentXterm) {
try {
const fitAddon = currentFitAddon as InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
fitAddon.fit();
const { cols, rows } = currentXterm;
if (currentResize) {
currentResize(cols, rows);
}
} catch {
// Ignore fit errors during viewport transitions
}
}
});
};
update(); // initial measurement
@@ -154,6 +181,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
// Cancel any pending deferred fit
if (pendingFitRef.current !== null) {
cancelAnimationFrame(pendingFitRef.current);
pendingFitRef.current = null;
}
setKeyboardOverlap(0);
setViewportHeight(null);
};

View File

@@ -1807,6 +1807,145 @@ describe("TerminalModal — virtual keyboard overlap handling", () => {
expect(overlay.style.getPropertyValue("--overlay-padding-top")).toBe("");
});
});
describe("xterm re-fit on keyboard open (FN-1043 regression)", () => {
/** Pending rAF callbacks keyed by fake id. */
let rafMap: Map<number, () => void>;
let nextRafId: number;
let originalRAF: typeof window.requestAnimationFrame;
let originalCAF: typeof window.cancelAnimationFrame;
beforeEach(() => {
rafMap = new Map();
nextRafId = 1;
originalRAF = window.requestAnimationFrame;
originalCAF = window.cancelAnimationFrame;
// Capture rAF callbacks with proper cancellation support so the
// coalescing logic (cancel → schedule) works correctly in tests.
window.requestAnimationFrame = ((cb: () => void) => {
const id = nextRafId++;
rafMap.set(id, cb);
return id;
}) as any;
window.cancelAnimationFrame = ((id: number) => {
rafMap.delete(id);
}) as any;
});
afterEach(() => {
window.requestAnimationFrame = originalRAF;
window.cancelAnimationFrame = originalCAF;
});
/** Flush all pending rAF callbacks and clear the map. */
function flushRaf() {
const callbacks = Array.from(rafMap.values());
rafMap.clear();
for (const cb of callbacks) cb();
}
it("defers fitAddon.fit() via requestAnimationFrame after keyboard open", async () => {
const { listeners } = simulateMobileDevice(250);
const mockResizeFn = vi.fn();
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
resize: mockResizeFn,
}));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Wait for --keyboard-overlap to be set (initial measurement + rAF)
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Flush any pending rAF from initial mount
act(() => { flushRaf(); });
// Clear the map before triggering the resize
rafMap.clear();
// Trigger a viewport resize (keyboard opened)
act(() => {
for (const cb of listeners.resize) cb();
});
// The rAF callback should have been scheduled
expect(rafMap.size).toBeGreaterThanOrEqual(1);
// Flush the rAF — this exercises the deferred fit logic.
// In the test env, fitAddonRef.current is null (xterm is mocked
// as a plain object, not wired into refs), so fit() won't actually
// run. We verify the mechanism by confirming rAF was used.
expect(() => {
act(() => { flushRaf(); });
}).not.toThrow();
});
it("coalesces rapid visualViewport resize events into a single rAF callback", async () => {
const { listeners } = simulateMobileDevice(250);
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
}));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Flush any pending rAF from initial mount
act(() => { flushRaf(); });
rafMap.clear();
// Fire multiple rapid resize events (keyboard animating open).
// Each event calls cancelAnimationFrame(previous) then requestAnimationFrame(new),
// so only 1 callback should remain in the map after 3 events.
act(() => {
for (const cb of listeners.resize) cb(); // event 1 → schedule rAF #1
for (const cb of listeners.resize) cb(); // event 2 → cancel #1, schedule rAF #2
for (const cb of listeners.resize) cb(); // event 3 → cancel #2, schedule rAF #3
});
// Only 1 rAF callback should survive (the last one)
expect(rafMap.size).toBe(1);
});
it("reads xterm refs inside the rAF callback (not stale closures)", async () => {
const { listeners } = simulateMobileDevice(250);
mockUseTerminal.mockReturnValue(createMockTerminalState({
connectionStatus: "connected",
resize: vi.fn(),
}));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
});
// Flush any pending rAF from initial mount
act(() => { flushRaf(); });
rafMap.clear();
// Trigger resize
act(() => {
for (const cb of listeners.resize) cb();
});
// Flush rAF — this should not throw even though xterm refs may be null
// in the test environment. The callback reads refs at call time, not capture time.
expect(() => {
act(() => { flushRaf(); });
}).not.toThrow();
});
});
});
// --- Close/reopen regression tests ---

View File

@@ -8064,6 +8064,10 @@ body {
inherited min-height or flex layout. */
height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)));
max-height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)));
/* Clip any content that exceeds the constrained height during
the keyboard-open transition so the terminal doesn't poke out
below the visible area while xterm re-fits. */
overflow: hidden;
}
}