diff --git a/src/renderer/src/components/ime-enter-guarded-form.test-events.ts b/src/renderer/src/components/ime-enter-guarded-form.test-events.ts new file mode 100644 index 00000000000..b48d46c860d --- /dev/null +++ b/src/renderer/src/components/ime-enter-guarded-form.test-events.ts @@ -0,0 +1,53 @@ +import { act, fireEvent } from '@testing-library/react' + +function dispatchKey( + input: HTMLInputElement, + type: 'keydown' | 'keyup', + init: KeyboardEventInit +): boolean { + const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }) + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + act(() => input.dispatchEvent(event)) + return event.defaultPrevented +} + +function dispatchImplicitSubmit(input: HTMLInputElement, init: KeyboardEventInit): boolean { + const prevented = dispatchKey(input, 'keydown', init) + if (!prevented) { + const form = input.closest('form') + if (!form) { + throw new Error('missing implicit-submit form') + } + act(() => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))) + } + return prevented +} + +export function dispatchRecordedImeImplicitSubmit(input: HTMLInputElement): boolean { + fireEvent.compositionStart(input) + dispatchKey(input, 'keydown', { + key: 'Process', + code: 'Enter', + keyCode: 229, + isComposing: true + }) + fireEvent.compositionEnd(input, { data: '가' }) + const prevented = dispatchImplicitSubmit(input, { + key: 'Enter', + code: 'Enter', + keyCode: 13, + isComposing: false + }) + dispatchKey(input, 'keyup', { key: 'Process', code: 'Enter', keyCode: 229 }) + dispatchKey(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 }) + return prevented +} + +export function dispatchOrdinaryImplicitSubmit(input: HTMLInputElement): boolean { + return dispatchImplicitSubmit(input, { + key: 'Enter', + code: 'Enter', + keyCode: 13, + isComposing: false + }) +} diff --git a/src/renderer/src/components/ime-enter-guarded-form.test.tsx b/src/renderer/src/components/ime-enter-guarded-form.test.tsx new file mode 100644 index 00000000000..9dda703fc9b --- /dev/null +++ b/src/renderer/src/components/ime-enter-guarded-form.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { dispatchOrdinaryImplicitSubmit } from './ime-enter-guarded-form.test-events' +import { ImeEnterGuardedForm } from './ime-enter-guarded-form' + +function dispatchKey(input: HTMLInputElement, init: KeyboardEventInit): boolean { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + act(() => input.dispatchEvent(event)) + return event.defaultPrevented +} + +afterEach(cleanup) + +describe('form-level Enter default prevention', () => { + it('allows browser implicit submission without a bubbled veto', async () => { + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + const user = userEvent.setup() + render( +
+ +
+ ) + + await user.click(screen.getByLabelText('unguarded')) + await user.keyboard('{Enter}') + + expect(onSubmit).toHaveBeenCalledOnce() + }) + + it('vetoes browser implicit submission from the bubbled form keydown', async () => { + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + let bubbledCurrentTarget: EventTarget | null = null + const onKeyDown = vi.fn((event: React.KeyboardEvent) => { + bubbledCurrentTarget = event.currentTarget + event.preventDefault() + }) + const user = userEvent.setup() + render( +
+ +
+ ) + + await user.click(screen.getByLabelText('guarded')) + await user.keyboard('{Enter}') + + expect(onKeyDown).toHaveBeenCalledOnce() + expect(bubbledCurrentTarget).toBeInstanceOf(HTMLFormElement) + expect(onSubmit).not.toHaveBeenCalled() + }) +}) + +describe('ImeEnterGuardedForm field ownership', () => { + it('resets the carry when focus moves between fields', () => { + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + render( + + + + + ) + const first = screen.getByLabelText('first') as HTMLInputElement + const second = screen.getByLabelText('second') as HTMLInputElement + + fireEvent.focus(first) + fireEvent.compositionStart(first) + expect( + dispatchKey(first, { + key: 'Process', + code: 'Enter', + keyCode: 229, + isComposing: true + }) + ).toBe(true) + fireEvent.compositionEnd(first, { data: '가' }) + fireEvent.blur(first) + fireEvent.focus(second) + + expect(dispatchOrdinaryImplicitSubmit(second)).toBe(false) + expect(onSubmit).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/ime-enter-guarded-form.tsx b/src/renderer/src/components/ime-enter-guarded-form.tsx new file mode 100644 index 00000000000..3c1e9847740 --- /dev/null +++ b/src/renderer/src/components/ime-enter-guarded-form.tsx @@ -0,0 +1,42 @@ +import type { ComponentProps } from 'react' +import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' + +export function ImeEnterGuardedForm({ + onBlur, + onCompositionEnd, + onCompositionStart, + onKeyDown, + onKeyUp, + ...props +}: ComponentProps<'form'>): React.JSX.Element { + const imeEnter = useImeEnterGestureOwnership() + + return ( +
{ + imeEnter.setComposing(true) + onCompositionStart?.(event) + }} + onCompositionEnd={(event) => { + imeEnter.setComposing(false) + onCompositionEnd?.(event) + }} + onKeyDown={(event) => { + if (imeEnter.ownsKeyDown(event)) { + event.preventDefault() + return + } + onKeyDown?.(event) + }} + onKeyUp={(event) => { + imeEnter.onKeyUp(event) + onKeyUp?.(event) + }} + onBlur={(event) => { + imeEnter.reset() + onBlur?.(event) + }} + /> + ) +} diff --git a/src/renderer/src/components/ui/command.test.tsx b/src/renderer/src/components/ui/command.test.tsx new file mode 100644 index 00000000000..bee2652f6b2 --- /dev/null +++ b/src/renderer/src/components/ui/command.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Command, CommandInput, CommandItem, CommandList } from './command' + +// cmdk puts its Enter->select dispatch on the Command root and guards it with +// only `isComposing || keyCode === 229`. macOS redispatches the Enter that +// confirms a CJK composition as an unmarked Enter/13, which that guard lets +// through. These pin the veto our root handler adds — the guard itself lives in +// a dependency and cannot be fixed at the source. + +let container: HTMLDivElement +let root: ReturnType + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function render(onSelect: () => void): HTMLInputElement { + act(() => { + root.render( + + + + + template + + + + ) + }) + return container.querySelector('input')! +} + +function key(input: HTMLInputElement, type: 'keydown' | 'keyup', init: KeyboardEventInit): void { + const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }) + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + Object.defineProperty(event, 'isComposing', { value: init.isComposing === true }) + act(() => input.dispatchEvent(event)) +} + +function composition( + input: HTMLInputElement, + type: 'compositionstart' | 'compositionend', + data = '' +) { + act(() => input.dispatchEvent(new CompositionEvent(type, { bubbles: true, data }))) +} + +// The carry expires on the NEXT FRAME, never synchronously — macOS delivers keyup +// before its unmarked redispatch. A human pressing a later deliberate Enter is many +// frames away, so advance one. +function advanceFrame(run: () => void): void { + let frame: FrameRequestCallback | undefined + const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + frame = cb + return 1 + }) + run() + act(() => frame?.(0)) + raf.mockRestore() +} + +describe('Command IME Enter ownership', () => { + it('selects the highlighted item on an ordinary Enter', () => { + const onSelect = vi.fn() + const input = render(onSelect) + + key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(onSelect).toHaveBeenCalledOnce() + }) + + // Recorded macOS 2-Set Korean shape: the confirming Enter arrives twice, and the + // second one is unmarked. Before the veto this created a file the user never asked + // for while their search text was still mid-word. + it('does not select on the unmarked Enter that macOS redispatches after compositionend', () => { + const onSelect = vi.fn() + const input = render(onSelect) + + composition(input, 'compositionstart') + key(input, 'keydown', { key: 'Process', code: 'Enter', keyCode: 229, isComposing: true }) + composition(input, 'compositionend', '가') + key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false }) + key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(onSelect).not.toHaveBeenCalled() + }) + + it('selects on the deliberate Enter that follows a confirmed composition', () => { + const onSelect = vi.fn() + const input = render(onSelect) + + composition(input, 'compositionstart') + key(input, 'keydown', { key: 'Process', code: 'Enter', keyCode: 229, isComposing: true }) + composition(input, 'compositionend', '가') + key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false }) + advanceFrame(() => key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })) + + key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false }) + + expect(onSelect).toHaveBeenCalledOnce() + }) + + // Windows/Linux redispatch the unmarked Enter BEFORE keyup, so the carry must not + // depend on the macOS ordering. + it('does not select when the redispatch precedes keyup', () => { + const onSelect = vi.fn() + const input = render(onSelect) + + composition(input, 'compositionstart') + key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 229, isComposing: true }) + key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false }) + composition(input, 'compositionend', '가') + key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 }) + + expect(onSelect).not.toHaveBeenCalled() + }) + + it('still forwards a consumer onKeyDown for keys the IME does not own', () => { + const onKeyDown = vi.fn() + act(() => { + root.render( + + + + a + + + ) + }) + const input = container.querySelector('input')! + + key(input, 'keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 }) + + expect(onKeyDown).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/ui/command.tsx b/src/renderer/src/components/ui/command.tsx index cbc9c0cf147..670e520ca5f 100644 --- a/src/renderer/src/components/ui/command.tsx +++ b/src/renderer/src/components/ui/command.tsx @@ -5,12 +5,57 @@ import { Command as CommandPrimitive } from 'cmdk' import { SearchIcon } from 'lucide-react' import { Dialog as DialogPrimitive } from 'radix-ui' +import { + isImeOwnedKeyboardEvent, + useImeEnterGestureOwnership +} from '@/lib/ime-composition-keyboard-event' import { cn } from '@/lib/utils' -function Command({ className, ...props }: React.ComponentProps) { +/** + * Why: cmdk owns the Enter->select dispatch on this root div, guarded by only + * `isComposing || keyCode === 229`. macOS redispatches the Enter that merely + * confirms a CJK composition as an unmarked `Enter`/13, which sails past that + * guard and selects whatever row is highlighted while the user is still + * mid-word. The guard lives in a dependency, but cmdk calls this handler before + * its own switch and skips on `defaultPrevented`, so vetoing here is the single + * seam that covers every CommandInput surface. + */ +function Command({ + className, + onCompositionEnd, + onCompositionStart, + onKeyDown, + onKeyUp, + ...props +}: React.ComponentProps) { + const imeEnter = useImeEnterGestureOwnership() + return ( { + if (imeEnter.ownsKeyDown(event)) { + // The hook cancels the carried redispatch itself; only an Enter marked + // by composition state alone still needs the veto cmdk would miss. + if (!isImeOwnedKeyboardEvent(event)) { + event.preventDefault() + } + return + } + onKeyDown?.(event) + }} + onKeyUp={(event) => { + imeEnter.onKeyUp(event) + onKeyUp?.(event) + }} + onCompositionStart={(event) => { + imeEnter.setComposing(true) + onCompositionStart?.(event) + }} + onCompositionEnd={(event) => { + imeEnter.setComposing(false) + onCompositionEnd?.(event) + }} className={cn( 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', className diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts index 5f3f11f8c62..d376b8ab09b 100644 --- a/src/renderer/src/lib/ime-composition-keyboard-event.ts +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -1,4 +1,4 @@ -import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react' type ImeKeyboardEvent = { isComposing?: boolean @@ -44,6 +44,99 @@ export function isMarkedImeOwnedShortcutEvent(event: object): boolean { return (event as { [IME_OWNED_SHORTCUT_EVENT]?: boolean })[IME_OWNED_SHORTCUT_EVENT] === true } +type ImeEnterGestureEvent = Pick< + ReactKeyboardEvent, + 'key' | 'keyCode' | 'nativeEvent' | 'preventDefault' | 'shiftKey' +> & { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } + +/** + * Why: the confirming Enter of a CJK composition arrives as two keydowns, and the + * two orderings differ by platform. Windows/Linux redispatch the unmarked + * `Enter`/13 *before* keyup; macOS delivers keyup first and redispatches after. + * A token that expires synchronously on keyup therefore regresses macOS, so the + * carry survives until the next animation frame. Identity-scoped so an older + * gesture's expiry cannot clear a newer one. + */ +export function useImeEnterGestureOwnership(): { + isComposing: () => boolean + ownsKeyDown: (event: ImeEnterGestureEvent) => boolean + onKeyUp: (event: ImeEnterGestureEvent) => void + reset: () => void + setComposing: (active: boolean) => void +} { + const stateRef = useRef<{ composing: boolean; pendingEnter: object | null }>({ + composing: false, + pendingEnter: null + }) + + return useMemo(() => { + const reset = (): void => { + stateRef.current = { composing: false, pendingEnter: null } + } + // Shift+Enter is a newline, never a submit — it must never be owned or swallowed. + const isPlainEnter = (event: ImeEnterGestureEvent): boolean => + event.key === 'Enter' && event.keyCode === 13 && !event.shiftKey + // The redispatched Enter of a confirm carries no modifiers, so a chorded one is the + // user's own submit aimed past the IME. It must still ARM, and must never be swallowed. + const hasChordModifier = (event: ImeEnterGestureEvent): boolean => + Boolean(event.altKey || event.ctrlKey || event.metaKey) + return { + isComposing: () => stateRef.current.composing, + ownsKeyDown: (event: ImeEnterGestureEvent): boolean => { + const markedEnter = + (event.nativeEvent.isComposing || stateRef.current.composing) && + (isPlainEnter(event) || + (event.key === 'Enter' && event.keyCode === 229) || + (event.key === 'Process' && event.keyCode === 229)) + if (markedEnter) { + stateRef.current.pendingEnter = {} + return true + } + if ( + stateRef.current.pendingEnter && + isPlainEnter(event) && + !event.nativeEvent.isComposing + ) { + // The gesture resolves either way, so the carry is spent either way; only a bare + // Enter is also swallowed, because a chorded one is the user's own submit. + stateRef.current.pendingEnter = null + if (hasChordModifier(event)) { + return false + } + event.preventDefault() + return true + } + return false + }, + onKeyUp: (event: ImeEnterGestureEvent): void => { + // A Process/229 keyup means the IME finished without redispatching, so the + // gesture is over immediately. + if (event.key === 'Process' && event.keyCode === 229) { + stateRef.current.pendingEnter = null + return + } + // Every other keyup expires on the NEXT FRAME, never synchronously. Enter/13 + // because macOS delivers keyup before the unmarked redispatch; anything else + // because IMEs reporting Process/229 on every key (Pinyin candidate selection) + // release a non-Enter key, and a Process-only clear left the carry armed and ate + // the user's next real Enter. + const pendingEnter = stateRef.current.pendingEnter + if (pendingEnter) { + requestAnimationFrame(() => { + if (stateRef.current.pendingEnter === pendingEnter) { + stateRef.current.pendingEnter = null + } + }) + } + }, + reset, + setComposing: (active: boolean) => { + stateRef.current.composing = active + } + } + }, []) +} + /** * Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that * only confirms a conversion candidate. Rename/title inputs that commit on diff --git a/src/renderer/src/lib/ime-enter-gesture-ownership-contract.test.ts b/src/renderer/src/lib/ime-enter-gesture-ownership-contract.test.ts new file mode 100644 index 00000000000..3ca2ac498ba --- /dev/null +++ b/src/renderer/src/lib/ime-enter-gesture-ownership-contract.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useImeEnterGestureOwnership } from './ime-composition-keyboard-event' + +/** + * The ownership contract for the confirming Enter of a CJK composition. Every case below is + * load-bearing; two of them describe bugs that were live on the same day, in opposite + * directions, and both are easy to reintroduce while "simplifying" the consume branch: + * + * - Drop `!hasChordModifier` from the consume branch and a chorded confirm is SWALLOWED — + * the user's Ctrl/Cmd+Enter submit silently does nothing. + * - Clear the carry only on the bare path and the chorded confirm leaves it ARMED — + * the user's next Enter is eaten instead. + * + * The carry must be spent on both paths; only a bare Enter is also consumed. + */ + +type Chord = { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } +type TestKeyEvent = ReactKeyboardEvent & { prevented: boolean } + +function enter( + opts: { key?: string; keyCode?: number; isComposing?: boolean } & Chord = {} +): TestKeyEvent { + return { + key: opts.key ?? 'Enter', + keyCode: opts.keyCode ?? 13, + altKey: opts.altKey ?? false, + ctrlKey: opts.ctrlKey ?? false, + metaKey: opts.metaKey ?? false, + shiftKey: opts.shiftKey ?? false, + nativeEvent: { isComposing: opts.isComposing ?? false }, + prevented: false, + preventDefault() { + ;(this as { prevented: boolean }).prevented = true + } + } as unknown as TestKeyEvent +} + +function ownership() { + return renderHook(() => useImeEnterGestureOwnership()).result +} + +/** Windows/Linux ordering: the IME redispatches the unmarked Enter before any keyup. */ +function confirmGesture(result: ReturnType, redispatch: TestKeyEvent): boolean { + result.current.setComposing(true) + result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true })) + result.current.setComposing(false) + return result.current.ownsKeyDown(redispatch) +} + +const CHORDS: Chord[] = [{ ctrlKey: true }, { altKey: true }, { metaKey: true }] + +describe('IME Enter gesture ownership — the four behaviours', () => { + it('blocks the unmarked Enter redispatched after compositionend', () => { + const result = ownership() + const redispatch = enter() + expect(confirmGesture(result, redispatch)).toBe(true) + expect(redispatch.prevented).toBe(true) + }) + + it('blocks a chord pressed during composition, so the preedit survives', () => { + for (const chord of CHORDS) { + const result = ownership() + result.current.setComposing(true) + expect(result.current.ownsKeyDown(enter({ isComposing: true, ...chord }))).toBe(true) + } + }) + + it('submits when a modifier is held through the confirm', () => { + for (const chord of CHORDS) { + const result = ownership() + const redispatch = enter(chord) + expect(confirmGesture(result, redispatch)).toBe(false) + expect(redispatch.prevented).toBe(false) + } + }) + + it('submits an ordinary Enter with no composition in flight', () => { + const result = ownership() + const ordinary = enter() + expect(result.current.ownsKeyDown(ordinary)).toBe(false) + expect(ordinary.prevented).toBe(false) + }) +}) + +describe('IME Enter gesture ownership — the carry is spent on both paths', () => { + it('does not eat the next Enter after a chorded confirm passed through', () => { + const result = ownership() + expect(confirmGesture(result, enter({ ctrlKey: true }))).toBe(false) + const next = enter() + expect({ owned: result.current.ownsKeyDown(next), prevented: next.prevented }).toEqual({ + owned: false, + prevented: false + }) + }) + + it('does not eat the next Enter even within the chord keyup frame', () => { + const result = ownership() + expect(confirmGesture(result, enter({ ctrlKey: true }))).toBe(false) + // The chord's own keyup only schedules the next-frame expiry, so an Enter landing + // before that frame turns must still reach the app. + result.current.onKeyUp(enter({ ctrlKey: true })) + const next = enter() + expect({ owned: result.current.ownsKeyDown(next), prevented: next.prevented }).toEqual({ + owned: false, + prevented: false + }) + }) + + it('spends the carry on a bare confirm too, so the following Enter is free', () => { + const result = ownership() + expect(confirmGesture(result, enter())).toBe(true) + expect(result.current.ownsKeyDown(enter())).toBe(false) + }) +}) + +describe('IME Enter gesture ownership — Shift+Enter is always a newline', () => { + it('never owns Shift+Enter, composing or on the redispatch', () => { + const result = ownership() + result.current.setComposing(true) + expect(result.current.ownsKeyDown(enter({ isComposing: true, shiftKey: true }))).toBe(false) + result.current.setComposing(false) + const redispatch = enter({ shiftKey: true }) + expect(result.current.ownsKeyDown(redispatch)).toBe(false) + expect(redispatch.prevented).toBe(false) + }) + + it('never owns Shift+Enter even while a real confirm is armed', () => { + const result = ownership() + expect(confirmGesture(result, enter({ shiftKey: true }))).toBe(false) + }) +}) + +describe('IME Enter gesture ownership — expiry timing', () => { + it('outlives a keyup delivered before the redispatch, as macOS does', () => { + const result = ownership() + result.current.setComposing(true) + result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true })) + result.current.setComposing(false) + // A synchronous clear here sends the composed text one keystroke early on macOS. + result.current.onKeyUp(enter()) + const redispatch = enter() + expect(result.current.ownsKeyDown(redispatch)).toBe(true) + expect(redispatch.prevented).toBe(true) + }) + + it('expires on the next frame rather than synchronously', async () => { + const result = ownership() + result.current.setComposing(true) + result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true })) + result.current.setComposing(false) + result.current.onKeyUp(enter()) + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + expect(result.current.ownsKeyDown(enter())).toBe(false) + }) + + it('clears immediately on a Process/229 keyup, which means the IME finished', () => { + const result = ownership() + result.current.setComposing(true) + result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true })) + result.current.setComposing(false) + result.current.onKeyUp(enter({ key: 'Process', keyCode: 229 })) + expect(result.current.ownsKeyDown(enter())).toBe(false) + }) +})