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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user