fix(ssh): keep the IME confirmation Enter from submitting the port-forward dialog (#34315)

The SSH port-forward dialog renders its four fields as raw inputs inside a
form with a submit button. IMEs that redispatch an unmarked Enter after
compositionend therefore trigger implicit submission with the half-typed
value. The pre-revert ImeEnterGuardedForm covered this form; #25480 moved
the guard into shared fields but did not reach these raw inputs, and the
#28609 lint rule only inspects Enter handlers, which these fields lack.

Render the fields with ImeInput so the shared gesture tracker swallows the
confirmation and its redispatch while ordinary Enter still submits.

Co-authored-by: Seiji Kohara <sage.0118@gmail.com>
This commit is contained in:
Neil
2026-10-27 01:03:19 -07:00
committed by GitHub
co-authored by Seiji Kohara
parent 80c9278b89
commit c0f9df5bea
2 changed files with 70 additions and 4 deletions
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { endComposition } from '@/lib/ime-composition-event.test-support'
import { SshPortForwardDialog } from './ssh-port-forward-dialog'
const addPortForward = vi.fn(() => Promise.resolve())
beforeEach(() => {
vi.useFakeTimers()
addPortForward.mockClear()
Object.defineProperty(window, 'api', {
configurable: true,
value: { ssh: { addPortForward } }
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
// happy-dom has no implicit submission, so a keydown left unprevented submits the form here.
function pressEnterWithImplicitSubmit(field: HTMLElement): boolean {
const notPrevented = fireEvent.keyDown(field, { key: 'Enter', keyCode: 13 })
if (notPrevented) {
fireEvent.submit(field.closest('form')!)
}
return notPrevented
}
describe('SshPortForwardDialog IME confirmation', () => {
it.each([
{ field: 'Remote Port', value: '8080', sent: { remotePort: 8080 } },
{ field: 'Local Port', value: '18080', sent: { localPort: 18080 } },
{ field: 'Remote Host', value: '開発機', sent: { remoteHost: '開発機' } },
{ field: 'Label (optional)', value: '開発', sent: { label: '開発' } }
])('keeps the confirm redispatch in $field from submitting', async ({ field, value, sent }) => {
render(
<SshPortForwardDialog
state={{ mode: 'add', defaults: { remotePort: 3000, targetId: 'target-1' } }}
activeConnectionId="target-1"
onClose={vi.fn()}
/>
)
const input = screen.getByLabelText(field)
fireEvent.compositionStart(input)
fireEvent.keyDown(input, { key: 'Process', keyCode: 229, isComposing: true })
fireEvent.change(input, { target: { value } })
endComposition(input, value)
fireEvent.keyUp(input, { key: 'Process', keyCode: 229 })
await act(async () => {
expect(pressEnterWithImplicitSubmit(input)).toBe(false)
})
expect(addPortForward).not.toHaveBeenCalled()
await act(async () => {
expect(pressEnterWithImplicitSubmit(input)).toBe(true)
})
expect(addPortForward).toHaveBeenCalledOnce()
expect(addPortForward).toHaveBeenCalledWith(expect.objectContaining(sent))
})
})
@@ -9,6 +9,7 @@ import {
import { Button } from '@/components/ui/button'
import type { PortForwardEntry } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { ImeInput } from '@/lib/ime-text-field'
// Remap privileged ports to avoid EACCES when binding locally without root.
function safeLocalPort(remotePort: number): number {
@@ -218,7 +219,7 @@ function PortForwardForm({
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.right.sidebar.PortsPanel.9e5a4118b0', 'Remote Port')}
</span>
<input
<ImeInput
type="text"
inputMode="numeric"
value={remotePort}
@@ -243,7 +244,7 @@ function PortForwardForm({
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.right.sidebar.PortsPanel.b950b1948b', 'Local Port')}
</span>
<input
<ImeInput
type="text"
inputMode="numeric"
value={localPort}
@@ -260,7 +261,7 @@ function PortForwardForm({
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.right.sidebar.PortsPanel.a3721a50b0', 'Remote Host')}
</span>
<input
<ImeInput
type="text"
value={remoteHost}
onChange={(e) => setRemoteHost(e.target.value)}
@@ -276,7 +277,7 @@ function PortForwardForm({
<span className="text-[11px] text-muted-foreground">
{translate('auto.components.right.sidebar.PortsPanel.8dfed0a15c', 'Label (optional)')}
</span>
<input
<ImeInput
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}