Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions scripts/test-ime-composition.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node

// Regression test for the IME composition guard (issues #637 / #296).
// Mirrors scripts/test-exec-command-timeout-cleanup.mjs: imports the *real*
// production predicate via esbuild transpile so the test runs against the
// actual source, not a copy.

import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { importTs } from './lib/ts-import.mjs';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const { isImeComposing } = await importTs(
path.join(root, 'src/renderer/src/utils/keyboard.ts'),
);

test('isImeComposing (IME composition guard)', async (t) => {
await t.test('treats the composition-confirming Enter (isComposing=true) as composing', () => {
// The Enter that confirms a kana-kanji / pinyin candidate.
assert.equal(isImeComposing({ isComposing: true }, 13), true);
});

await t.test('treats the legacy keyCode 229 as composing', () => {
// Older Chromium engines report 229 instead of setting isComposing.
assert.equal(isImeComposing({}, 229), true);
});

await t.test('treats a plain (non-composing) Enter as NOT composing', () => {
assert.equal(isImeComposing({ isComposing: false }, 13), false);
});

await t.test('treats a non-IME key without isComposing as NOT composing', () => {
assert.equal(isImeComposing({}, 13), false);
});

await t.test('tolerates a null/undefined native event', () => {
assert.equal(isImeComposing(null, 229), true);
assert.equal(isImeComposing(undefined, 13), false);
});
});
7 changes: 7 additions & 0 deletions src/renderer/src/hooks/useLauncherKeyboardControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isBrowserSearchCommand } from '../utils/browser-search-commands';
import { WEB_SEARCH_ROOT_BANG_PREFIX } from '../utils/web-search-bangs';
import { isEditableElement } from '../utils/launcher-misc';
import { LAST_LAUNCHER_QUERY_KEY } from '../utils/constants';
import { isImeComposing } from '../utils/keyboard';

function readLauncherQueryHistory(): string[] {
try {
Expand Down Expand Up @@ -246,6 +247,12 @@ export function useLauncherKeyboardControls(

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Ignore the Enter (and any key) that confirms a CJK IME composition
// candidate — otherwise the launcher submits/closes mid-composition
// (#637 / #296). Must run before every other branch.
if (isImeComposing(e.nativeEvent, e.keyCode)) {
return;
}
if (showAppUninstall) {
return;
}
Expand Down
33 changes: 33 additions & 0 deletions src/renderer/src/utils/keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Pure predicates for keyboard-event handling shared across the launcher
// (search input, snippet manager, …). Intentionally dependency-free so it can
// be exercised directly by `scripts/test-ime-composition.mjs` via
// `scripts/lib/ts-import.mjs` (esbuild transpile, no relative imports).

/**
* Returns `true` when a keydown/keyup event is fired while a CJK
* (Japanese / Chinese / Korean) Input Method Editor composition is in
* progress.
*
* The DOM signals this two ways, depending on the engine:
* 1. `KeyboardEvent.isComposing === true` — the modern, correct signal.
* 2. `keyCode === 229` — the legacy value Chromium/IE emit for the key that
* confirms/inserts an IME candidate before the composed text is committed.
*
* The Enter that *confirms* a kana-kanji / pinyin candidate therefore arrives
* looking like a normal Enter (`key === 'Enter'`, `keyCode === 13`) but with
* `isComposing === true` (or as `keyCode === 229` on older engines). Without a
* guard it is misread as a "submit" and closes / runs the launcher
* mid-composition. Callers should `return` early when this returns `true`.
*
* @param nativeEvent The native `KeyboardEvent` (e.g. `e.nativeEvent`). Only
* its `isComposing` flag is read, so a structural subset is accepted and
* `null`/`undefined` are tolerated.
* @param keyCode The legacy `keyCode` of the event (e.g. `e.keyCode`).
*/
export function isImeComposing(
nativeEvent: { isComposing?: boolean } | null | undefined,
keyCode?: number,
): boolean {
if (nativeEvent?.isComposing) return true;
return keyCode === 229;
}