fix(mobile): recompose iOS decomposed (NFD) Hangul before it reaches the PTY

Apple text input hands React Native decomposed text, so one Hangul syllable
arrives as 2-3 conjoining jamo (안 = U+110B U+1161 U+11AB). The live-input
mirror holds only the trailing code point as the in-composition syllable, so
every leading jamo is committed to the PTY on its own and is never recombined:
typing 안녕하세요 lands 12 lone jamo instead of 5 syllables.

This is a third mechanism behind the #6995 symptom, distinct from the Samsung
Keyboard inputType kill on Android — the iOS field itself composes correctly,
it is the string handed to JS that is decomposed, which is why the report
survives autoCorrect={false}.

NFC-normalize at the existing normalizeTerminalTextInput choke point, which
covers both the live mirror and the command-bar send. NFC only, never NFKC:
NFKC folds compatibility jamo and full-width forms into different characters
and would silently rewrite a command. The field write-back stays verbatim, so
the iOS dictation/IME constraint from #7925 is untouched.

Fixes #11235
Refs #6995, #7925
This commit is contained in:
Brennan Benson
2026-07-28 16:35:26 -07:00
parent 638c3ca5d9
commit 949099d7db
3 changed files with 126 additions and 1 deletions
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep
} from './terminal-live-hangul-mirror'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
// 두벌식 (2-set) composition states for 안녕하세요, as the field shows them
// keystroke by keystroke: ㅇ ㅏ ㄴ ㄴ ㅕ ㅇ ㅎ ㅏ ㅅ ㅔ ㅇ ㅛ.
const DUBEOLSIK_FIELD_STATES = [
'ㅇ',
'아',
'안',
'안ㄴ',
'안녀',
'안녕',
'안녕ㅎ',
'안녕하',
'안녕핫',
'안녕하세',
'안녕하셍',
'안녕하세요'
] as const
const DEL = '\x7f'
// Minimal PTY line model: DEL erases one code point, everything else appends.
function applyToPty(pty: string, payload: string): string {
let next = Array.from(pty)
for (const codePoint of Array.from(payload)) {
if (codePoint === DEL) {
next = next.slice(0, -1)
continue
}
next.push(codePoint)
}
return next.join('')
}
// Replays handleLiveInputChange → applyLiveInputMirror, then the settle-timer
// commit that flushes the last held syllable.
function replayLiveInput(fieldStates: readonly string[]): string {
let sentText = ''
let heldText = ''
let pty = ''
const step = (fieldText: string, commitHeld: boolean): void => {
const mirror = computeTerminalLiveMirrorStep(sentText, normalizeTerminalTextInput(fieldText), {
commitHeld
})
pty = applyToPty(pty, buildTerminalLiveMirrorPayload(mirror))
sentText = mirror.nextSentText
heldText = mirror.heldText
}
for (const fieldText of fieldStates) {
step(fieldText, false)
}
step(sentText + heldText, true)
return pty
}
describe('iOS decomposed (NFD) Hangul live input', () => {
it('Given an NFD field (iOS) When the user types 안녕하세요 Then the PTY receives composed syllables, not jamo', () => {
// Given: Apple text input hands React Native decomposed conjoining jamo, so
// one syllable arrives as 2-3 code points instead of one.
const nfdStates = DUBEOLSIK_FIELD_STATES.map((state) => state.normalize('NFD'))
expect(Array.from(nfdStates.at(-1) ?? '')).toHaveLength(12)
// When
const pty = replayLiveInput(nfdStates)
// Then: the mirror holds only the trailing code point, so without
// recomposition every leading jamo is committed on its own (#6995 iOS).
expect(Array.from(pty)).toHaveLength(5)
expect(pty).toBe('안녕하세요')
})
it('Given a precomposed NFC field (Android/Gboard) When the same keys are typed Then the PTY result is unchanged', () => {
// Given / When
const pty = replayLiveInput(DUBEOLSIK_FIELD_STATES)
// Then
expect(pty).toBe('안녕하세요')
})
it('Given NFD Latin diacritics When mirrored Then combining marks reach the PTY already composed', () => {
// Given / When: iOS also hands back NFD for Vietnamese/European accents.
const pty = replayLiveInput(['tie', 'tiế', 'tiếng'].map((state) => state.normalize('NFD')))
// Then
expect(pty).toBe('tiếng')
expect(Array.from(pty)).toHaveLength(5)
})
})
@@ -11,4 +11,29 @@ describe('normalizeTerminalTextInput', () => {
it('keeps ASCII hyphens unchanged', () => {
expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
})
it('recomposes decomposed Hangul that iOS hands back as conjoining jamo', () => {
const decomposed = '안녕하세요'.normalize('NFD')
expect(Array.from(decomposed)).toHaveLength(12)
const normalized = normalizeTerminalTextInput(decomposed)
expect(normalized).toBe('안녕하세요')
expect(Array.from(normalized)).toHaveLength(5)
})
it('recomposes decomposed Latin diacritics and stays idempotent on composed text', () => {
expect(normalizeTerminalTextInput('tiếng'.normalize('NFD'))).toBe('tiếng')
expect(normalizeTerminalTextInput('안녕하세요')).toBe('안녕하세요')
expect(normalizeTerminalTextInput(normalizeTerminalTextInput('café'.normalize('NFD')))).toBe(
'café'
)
})
it('leaves compatibility forms alone so NFC never becomes a lossy NFKC fold', () => {
// NFKC would fold these into different characters and silently rewrite a command.
expect(normalizeTerminalTextInput('ㅇㅏㄴ')).toBe('ㅇㅏㄴ')
expect(normalizeTerminalTextInput('git')).toBe('git')
expect(normalizeTerminalTextInput('①')).toBe('①')
})
})
@@ -4,6 +4,11 @@
// value written back into the field, and that write-back kills iOS dictation (#7925).
const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g
// Why: Apple text input hands back decomposed (NFD) text, so one Hangul syllable
// arrives as 2-3 conjoining jamo. The live mirror holds only the trailing code
// point, so every leading jamo would commit on its own (#6995 iOS). NFC — never
// NFKC, which rewrites compatibility jamo and full-width forms — recomposes them
// before the PTY sees anything; it is a no-op for already-composed input.
export function normalizeTerminalTextInput(text: string): string {
return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
return text.normalize('NFC').replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
}