fix(FN-000): skip iOS gesture focus handler so typed keys reach xterm
On iOS Safari/PWA, tapping the terminal opened the on-screen keyboard but
keystrokes were silently dropped. After the earlier CSS fix (c7266b7f) the
helper textarea now covers the terminal surface and iOS focuses it natively
on tap — but the bubble-phase onPointerDown/onTouchStart handler kept
re-focusing xterm + the textarea and calling setSelectionRange during the
touch gesture. That is the same class of re-focus-mid-gesture that the
prior commit identified as disrupting iOS input attribution; moving from
capture to bubble phase wasn't enough.
- Early-return from handleTerminalGestureFocus on
(hover: none) and (pointer: coarse), so iOS handles focus with no JS
interference.
- Desktop (fine pointer) keeps the existing behavior because the textarea
stays 1x1 off-screen and still needs programmatic focus on canvas click.
- Add a regression test covering the no-op path with the media query mocked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-ios-terminal-input.md
Normal file
5
.changeset/fix-ios-terminal-input.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix iOS terminal typing in the dashboard. On touch-primary devices, tapping the terminal opened the on-screen keyboard but keystrokes were silently dropped because the bubble-phase `handleTerminalGestureFocus` handler re-focused the helper textarea and reset its selection during touchstart/pointerdown, disrupting iOS's input-event attribution. The CSS fix in commit c7266b7f already positions the textarea to receive taps natively, so the JS handler is now a no-op on `(hover: none) and (pointer: coarse)` devices and desktop retains click-to-focus.
|
||||
@@ -683,10 +683,25 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
* On mobile browsers, opening the soft keyboard requires focus to happen
|
||||
* within a real user gesture. Programmatic focus in async effects is often
|
||||
* ignored even though xterm stays connected and receives output.
|
||||
*
|
||||
* On touch-primary devices, the CSS sizes `.xterm-helper-textarea` to cover
|
||||
* the whole terminal surface (see styles.css @media (hover: none) and
|
||||
* (pointer: coarse)), so iOS focuses it natively on tap. Re-focusing and
|
||||
* calling setSelectionRange inside the touchstart/pointerdown handler
|
||||
* disrupts iOS's input-event attribution (same class of bug the prior
|
||||
* capture-phase handlers caused — see commit c7266b7f), and subsequent
|
||||
* keystrokes are silently dropped. Early-return on touch-primary so iOS
|
||||
* handles focus with no JS interference.
|
||||
*/
|
||||
const handleTerminalGestureFocus = useCallback(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const isTouchPrimary =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(hover: none) and (pointer: coarse)")?.matches === true;
|
||||
if (isTouchPrimary) return;
|
||||
|
||||
// Ensure xterm updates its own focus state first.
|
||||
xtermRef.current?.focus();
|
||||
|
||||
|
||||
@@ -3646,6 +3646,50 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
expect(helperTextarea.autocorrect).toBe("off");
|
||||
expect(helperTextarea.spellcheck).toBe(false);
|
||||
});
|
||||
|
||||
// On touch-primary devices (iOS, Android), the CSS sizes the helper textarea
|
||||
// to cover the whole terminal so iOS focuses it natively on tap. Re-focusing
|
||||
// in the bubble-phase gesture handler disrupts iOS input-event attribution
|
||||
// and causes typed keys to be silently dropped. The handler must be a no-op
|
||||
// in that environment — see commit c7266b7f for prior iOS input fix context.
|
||||
it("no-ops gesture focus handler on touch-primary devices", async () => {
|
||||
const matchMediaSpy = vi
|
||||
.spyOn(window, "matchMedia")
|
||||
.mockImplementation((query: string) => ({
|
||||
matches: query === "(hover: none) and (pointer: coarse)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
try {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||||
const helperTextarea = document.createElement("textarea");
|
||||
helperTextarea.className = "xterm-helper-textarea";
|
||||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||||
const setSelectionRangeSpy = vi.spyOn(helperTextarea, "setSelectionRange");
|
||||
terminalDiv.appendChild(helperTextarea);
|
||||
|
||||
mockTerminalInstance.focus.mockClear();
|
||||
fireEvent.touchStart(terminalDiv);
|
||||
|
||||
expect(mockTerminalInstance.focus).not.toHaveBeenCalled();
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
expect(setSelectionRangeSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
matchMediaSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- FN-1765: Project-context propagation ---
|
||||
|
||||
Reference in New Issue
Block a user