Univer InputNumber 的 keydown 对 ArrowUp/Down 直接 preventDefault 且无 isComposing 守卫,中文输入法组合期候选词方向键被吞(#562 回归,同类 #535)。 新增 src/client/ime-guard.ts:document capture 阶段对组合期按键(isComposing 或 keyCode 229)stopPropagation,运行于 React 委托与任何原生监听之前,覆盖 内联依赖的全部劫持路径;apply 时经 ctx.effect 注册(HMR-safe)。 - tests/ime-guard.spec.ts:12 个用例,覆盖纯决策、原生 document/input 监听、 React 合成事件路径与 disposer 恢复(新增 jsdom devDependency) - 全量 280 测试通过,typecheck 干净
188 lines
7.1 KiB
TypeScript
188 lines
7.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* IME-composition guard tests.
|
|
*
|
|
* The guard registers document CAPTURE-phase keydown/keyup listeners that
|
|
* stopPropagation() while an input method is composing (isComposing or the
|
|
* legacy keyCode 229). These tests pin:
|
|
*
|
|
* 1. the pure decision (`isImeComposition`);
|
|
* 2. the native-listener path — a bubble listener on `document` and a
|
|
* target-phase listener on the input must NOT see composition keys, but
|
|
* must see every other key;
|
|
* 3. the React-synthetic path — a React `onKeyDown` handler (delegated at
|
|
* the root container) must not fire for composition keys, proving the
|
|
* document-capture listener wins the ordering race against React's
|
|
* delegation (the mechanism that inlined third-party UI, e.g. Univer's
|
|
* InputNumber, uses to hijack ArrowUp/ArrowDown);
|
|
* 4. the disposer restores normal flow (HMR-safe).
|
|
*
|
|
* Events are dispatched on a deep element (an `<input>` in `document.body`)
|
|
* exactly like the browser does, so capture-phase blocking behaves like in
|
|
* production — dispatching on `document` itself would run all listeners in
|
|
* the target phase where stopPropagation does not stop same-node listeners.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { createElement } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import { act } from 'react-dom/test-utils'
|
|
import { isImeComposition, registerImeGuard } from '../src/client/ime-guard.ts'
|
|
|
|
/** Build a KeyboardEvent the way jsdom allows: keyCode/isComposing via defineProperty. */
|
|
function keyEvent(
|
|
type: 'keydown' | 'keyup',
|
|
init: { key: string; isComposing?: boolean; keyCode?: number; bubbles?: boolean },
|
|
): KeyboardEvent {
|
|
const event = new KeyboardEvent(type, { key: init.key, bubbles: init.bubbles ?? true, cancelable: true })
|
|
if (init.isComposing !== undefined) {
|
|
Object.defineProperty(event, 'isComposing', { value: init.isComposing })
|
|
}
|
|
if (init.keyCode !== undefined) {
|
|
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
|
|
}
|
|
return event
|
|
}
|
|
|
|
describe('isImeComposition', () => {
|
|
it('treats isComposing as composition', () => {
|
|
expect(isImeComposition({ isComposing: true, keyCode: 0 })).toBe(true)
|
|
})
|
|
|
|
it('treats keyCode 229 as composition (legacy engines without isComposing)', () => {
|
|
expect(isImeComposition({ isComposing: false, keyCode: 229 })).toBe(true)
|
|
})
|
|
|
|
it('treats both signals together as composition', () => {
|
|
expect(isImeComposition({ isComposing: true, keyCode: 229 })).toBe(true)
|
|
})
|
|
|
|
it('lets ordinary keys through', () => {
|
|
expect(isImeComposition({ isComposing: false, keyCode: 40 })).toBe(false)
|
|
expect(isImeComposition({ isComposing: false, keyCode: 0 })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('registerImeGuard — native listeners', () => {
|
|
let input: HTMLInputElement
|
|
let dispose: (() => void) | undefined
|
|
const seen: string[] = []
|
|
|
|
const onDocumentBubble = (event: Event): void => {
|
|
seen.push(`document:${event.type}`)
|
|
}
|
|
const onInputTarget = (event: Event): void => {
|
|
seen.push(`input:${event.type}`)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
input = document.createElement('input')
|
|
document.body.appendChild(input)
|
|
seen.length = 0
|
|
document.addEventListener('keydown', onDocumentBubble)
|
|
document.addEventListener('keyup', onDocumentBubble)
|
|
input.addEventListener('keydown', onInputTarget)
|
|
input.addEventListener('keyup', onInputTarget)
|
|
})
|
|
|
|
afterEach(() => {
|
|
dispose?.()
|
|
dispose = undefined
|
|
document.removeEventListener('keydown', onDocumentBubble)
|
|
document.removeEventListener('keyup', onDocumentBubble)
|
|
input.removeEventListener('keydown', onInputTarget)
|
|
input.removeEventListener('keyup', onInputTarget)
|
|
input.remove()
|
|
})
|
|
|
|
it('lets ordinary keys reach document bubble and input target listeners', () => {
|
|
dispose = registerImeGuard()
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown' }))
|
|
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown' }))
|
|
expect(seen).toEqual(['input:keydown', 'document:keydown', 'input:keyup', 'document:keyup'])
|
|
})
|
|
|
|
it('blocks composition keys (isComposing) from every downstream listener', () => {
|
|
dispose = registerImeGuard()
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
input.dispatchEvent(keyEvent('keyup', { key: 'ArrowDown', isComposing: true }))
|
|
expect(seen).toEqual([])
|
|
})
|
|
|
|
it('blocks composition keys signalled by keyCode 229 only', () => {
|
|
dispose = registerImeGuard()
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', keyCode: 229 }))
|
|
expect(seen).toEqual([])
|
|
})
|
|
|
|
it('still blocks after the composition signal is gone (guard is per-event)', () => {
|
|
dispose = registerImeGuard()
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
|
|
expect(seen).toEqual(['input:keydown', 'document:keydown'])
|
|
})
|
|
|
|
it('disposer restores normal flow', () => {
|
|
dispose = registerImeGuard()
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
expect(seen).toEqual([])
|
|
dispose()
|
|
dispose = undefined
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
expect(seen).toEqual(['input:keydown', 'document:keydown'])
|
|
})
|
|
})
|
|
|
|
describe('registerImeGuard — React synthetic path', () => {
|
|
let container: HTMLDivElement
|
|
let root: Root
|
|
let input: HTMLInputElement
|
|
let dispose: (() => void) | undefined
|
|
const seen: string[] = []
|
|
|
|
const onReactKeyDown = (): void => {
|
|
seen.push('react:keydown')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement('div')
|
|
document.body.appendChild(container)
|
|
root = createRoot(container)
|
|
act(() => {
|
|
root.render(createElement('input', { 'data-testid': 'ime-target', onKeyDown: onReactKeyDown }))
|
|
})
|
|
input = container.querySelector('input') as HTMLInputElement
|
|
seen.length = 0
|
|
})
|
|
|
|
afterEach(() => {
|
|
dispose?.()
|
|
dispose = undefined
|
|
act(() => { root.unmount() })
|
|
container.remove()
|
|
})
|
|
|
|
it('baseline: React onKeyDown receives composition keys when the guard is absent', () => {
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
expect(seen).toEqual(['react:keydown'])
|
|
})
|
|
|
|
it('guard blocks React synthetic onKeyDown during composition (capture beats delegation)', () => {
|
|
dispose = registerImeGuard()
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
expect(seen).toEqual([])
|
|
// Ordinary keys still flow through React after the guard is in place.
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: false }))
|
|
expect(seen).toEqual(['react:keydown'])
|
|
})
|
|
|
|
it('disposer restores the React synthetic path', () => {
|
|
dispose = registerImeGuard()
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
expect(seen).toEqual([])
|
|
dispose()
|
|
dispose = undefined
|
|
input.dispatchEvent(keyEvent('keydown', { key: 'ArrowDown', isComposing: true }))
|
|
expect(seen).toEqual(['react:keydown'])
|
|
})
|
|
})
|