Skip to content

Commit 3298400

Browse files
alexisareyngeorgylobko
authored andcommitted
fix: Input IME enter to complete composition bug
Fix race condition where compositionend fires before onKeyDown handler, causing premature search submission when users press Enter to complete character composition.
1 parent 3dcd7e0 commit 3298400

File tree

4 files changed

+151
-1
lines changed

4 files changed

+151
-1
lines changed

pages/input/korean-ime.page.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useState } from 'react';
5+
6+
import { Box, FormField, SpaceBetween } from '~components';
7+
import Input from '~components/input';
8+
9+
import { SimplePage } from '../app/templates';
10+
11+
export default function InputKoreanIMEPage() {
12+
const [searchText, setSearchText] = useState('');
13+
const [searchResults, setSearchResults] = useState<string[]>([]);
14+
15+
const handleKeyDown = (event: CustomEvent<any>) => {
16+
if (event.detail.keyCode === 13 && searchText.trim()) {
17+
setSearchResults(prev => [
18+
`Search executed: ${searchText}`,
19+
...prev.slice(0, 9), // Keep last 10 results
20+
]);
21+
}
22+
};
23+
24+
return (
25+
<SimplePage
26+
title="Input Korean IME Test"
27+
subtitle="Type Korean characters and press Enter - should complete character first, not search"
28+
>
29+
<SpaceBetween size="m">
30+
<Box variant="awsui-key-label">
31+
<strong>Test Instructions:</strong>
32+
<ol>
33+
<li>Enable Korean 2-set keyboard</li>
34+
<li>Type Korean character: ㄱ + ㅏ (forms 가)</li>
35+
<li>Press Enter → Should complete character, NOT show search result</li>
36+
<li>Press Enter again → Should show search result with 가</li>
37+
</ol>
38+
</Box>
39+
40+
<FormField
41+
label="Search Input"
42+
description="Watch the results below - Enter during composition shouldn't trigger search"
43+
>
44+
<Input
45+
value={searchText}
46+
onChange={({ detail }) => setSearchText(detail.value)}
47+
onKeyDown={handleKeyDown}
48+
ariaLabel="Korean IME test search"
49+
placeholder="가족, 한글, etc."
50+
type="search"
51+
/>
52+
</FormField>
53+
54+
<Box>
55+
<strong>Current Input:</strong> {searchText || '(empty)'}
56+
</Box>
57+
58+
<Box>
59+
<strong>Search Results:</strong>
60+
{searchResults.length === 0 ? (
61+
<Box margin={{ top: 'xs' }} color="text-status-inactive">
62+
No searches yet
63+
</Box>
64+
) : (
65+
<Box margin={{ top: 'xs' }}>
66+
{searchResults.map((result, index) => (
67+
<Box
68+
key={index}
69+
padding={{ bottom: 'xs' }}
70+
color={index === 0 ? 'text-status-success' : 'text-body-secondary'}
71+
>
72+
{result}
73+
</Box>
74+
))}
75+
</Box>
76+
)}
77+
</Box>
78+
</SpaceBetween>
79+
</SimplePage>
80+
);
81+
}

src/input/__integ__/input.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,35 @@ describe('Input', () => {
4545
).resolves.toBe(true);
4646
})
4747
);
48+
49+
test(
50+
'Should not submit form during IME composition',
51+
useBrowser(async browser => {
52+
await browser.url('#/light/input/input-integ');
53+
const page = new InputPage(browser);
54+
await page.focusInput();
55+
56+
// Simulate IME composition
57+
await browser.execute(() => {
58+
const input = document.querySelector('input')!;
59+
input.dispatchEvent(new CompositionEvent('compositionstart'));
60+
});
61+
62+
await page.keys(['Enter']);
63+
64+
// Form should not be submitted during composition
65+
await expect(page.isFormSubmitted()).resolves.toBe(false);
66+
67+
// End composition
68+
await browser.execute(() => {
69+
const input = document.querySelector('input')!;
70+
input.dispatchEvent(new CompositionEvent('compositionend', { data: '가' }));
71+
});
72+
73+
await page.keys(['Enter']);
74+
75+
// Form should not be submitted during composition
76+
await expect(page.isFormSubmitted()).resolves.toBe(true);
77+
})
78+
);
4879
});

src/input/__tests__/input.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,4 +515,31 @@ describe('Input', () => {
515515
expect(input).toHaveClass('additional-class');
516516
});
517517
});
518+
519+
describe('IME composition', () => {
520+
test('does not trigger onKeyDown handler when Enter pressed during active IME composition', () => {
521+
const onKeyDown = jest.fn();
522+
const { wrapper, input } = renderInput({ value: '가', onKeyDown });
523+
524+
input.dispatchEvent(new CompositionEvent('compositionstart'));
525+
wrapper.findNativeInput().keydown({ keyCode: 13, isComposing: false });
526+
527+
expect(onKeyDown).not.toHaveBeenCalled();
528+
529+
input.dispatchEvent(new CompositionEvent('compositionend', { data: '가' }));
530+
});
531+
532+
test('allows onKeyDown handler after IME composition ends', async () => {
533+
const onKeyDown = jest.fn();
534+
const { wrapper, input } = renderInput({ value: '가', onKeyDown });
535+
536+
input.dispatchEvent(new CompositionEvent('compositionstart'));
537+
input.dispatchEvent(new CompositionEvent('compositionend', { data: '가' }));
538+
539+
await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
540+
541+
wrapper.findNativeInput().keydown({ keyCode: 13 });
542+
expect(onKeyDown).toHaveBeenCalled();
543+
});
544+
});
518545
});

src/input/internal.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { FormFieldValidationControlProps, useFormFieldContext } from '../interna
1818
import { fireKeyboardEvent, fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events';
1919
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
2020
import { useDebounceCallback } from '../internal/hooks/use-debounce-callback';
21+
import { useIMEComposition } from '../internal/hooks/use-ime-composition';
2122
import WithNativeAttributes, { SkipWarnings } from '../internal/utils/with-native-attributes';
2223
import {
2324
GeneratedAnalyticsMetadataInputClearInput,
@@ -108,6 +109,7 @@ function InternalInput(
108109
};
109110

110111
const inputRef = useRef<HTMLInputElement>(null);
112+
const { isComposing } = useIMEComposition(inputRef);
111113
const searchProps = useSearchProps(type, disabled, readOnly, value, inputRef, handleChange);
112114
__leftIcon = __leftIcon ?? searchProps.__leftIcon;
113115
__rightIcon = __rightIcon ?? searchProps.__rightIcon;
@@ -148,7 +150,16 @@ function InternalInput(
148150
step,
149151
inputMode,
150152
spellCheck: spellcheck,
151-
onKeyDown: onKeyDown && (event => fireKeyboardEvent(onKeyDown, event)),
153+
onKeyDown:
154+
onKeyDown &&
155+
(event => {
156+
// Prevent keydown event during IME composition to avoid race conditions
157+
if (isComposing() && event.key === 'Enter') {
158+
event.preventDefault();
159+
return;
160+
}
161+
fireKeyboardEvent(onKeyDown, event);
162+
}),
152163
onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)),
153164
// We set a default value on the component in order to force it into the controlled mode.
154165
value: value ?? '',

0 commit comments

Comments
 (0)