fix: Broken first-character IME composition on Android (Hangul jamo separation) - #3066
fix: Broken first-character IME composition on Android (Hangul jamo separation)#3066melodysdreamj wants to merge 4 commits into
Conversation
…eparation) Patch slate-react's Android input path via patch-package: keep the mid-composition flush and RestoreDOM/TextString DOM rewrites from destroying the text node the IME is composing into when the leaf is still empty in the model. Typing the first character with a composing keyboard (Korean, Japanese, pinyin, GBoard autocorrect English) no longer breaks composition. Upstream: ianstormtaylor/slate#5883 (regression made deterministic by ianstormtaylor/slate#5901, present through slate-react 0.126.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
I have read the CLA Document and I hereby sign the CLA You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot. |
|
Confirmed on a physical Android phone running this patch in a real Cinny deployment, not a test page. Typing 안녕 into an empty composer with a Korean 2-bulsik keyboard, traced from the composer element: The message sent as The upstream fix is now open as ianstormtaylor/slate#6096 with the same device trace; this patch can be dropped once that lands and a slate-react release carrying it is picked up. |
The deferral that keeps composition alive had one exit: compositionend. That event is not fired reliably, so a stuck composition could leave the typed text out of the editor value entirely. A composition that has lost focus or gone quiet for 5s no longer holds the flush back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Two follow-ups, both now in the patch. Closed a hole in the deferral. The deferral that keeps composition alive had exactly one exit — Verified beyond Korean, on the same Pixel/Android 14/Gboard emulator, typing into an empty composer:
The Japanese case is the decisive one: dakuten modifies the character already being composed, so a composition broken at the first character would leave The upstream PR (ianstormtaylor/slate#6096) carries the same two changes. |
Replacing scheduleAction with a pending selection made the caret lag behind by one character for ~200ms after committing a syllable and pressing space. The flush gate alone keeps composition alive, so that hunk was never needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Dropped one hunk after a report from a device tester: after committing a syllable and pressing space, the caret sat a character behind for a moment before jumping forward. That was mine. The patch had been replacing slate's
The hunk was also unnecessary: its job was to stop a scheduled flush from landing mid-composition, and the flush gate in this patch already refuses that. Removing it leaves slate's caret handling untouched. Re-verified with the hunk gone: Korean at both slow and fast pace, composing English, first character in a block created by Enter, the submit race and the mid-composition value check all pass, and 안녕 still composes correctly with the real Gboard on an Android 14 emulator. The patch is now 4 hunks instead of 5. The upstream PR (ianstormtaylor/slate#6096) carries the same correction. |
The deferral alone left cancellation paths inconsistent: backspacing the only composing character stranded it in the DOM and closed the Android keyboard. Root cause of that whole class: Android empty leaves rendered no text node, so the IME composed into a node React had to destroy. - Render the same zero-width space other platforms get, so composition lives in a React-owned node and characterData is all the browser edits - Keep flushes deferred only while the target leaf is empty; apply synchronously at compositionend (unchanged) - On cancellation (empty insertCompositionText), drop the deferred diffs instead of applying and re-deleting - the value is already in the desired state, so nothing churns mid-composition - After a cancelled composition ends, force one render so RestoreDOM puts the leaf DOM back for the next composition - Drop the RestoreDOM childList guard; with a stable text node it was only needed for the node the browser used to create Verified on a Gboard emulator: first character composes, backspacing the last character deletes it with the keyboard staying open (3/3), and typing again right after a cancel composes cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Reworked the patch after device testing caught a regression it caused: backspacing away the only composing character (type one jamo, press backspace) left the character in the DOM and could close the Android keyboard. The rework goes one level deeper than the original patch. On Android an empty leaf rendered no text node at all, so the first composition lived in a browser-created node that no re-render could preserve — that is what made all the DOM-guarding necessary. Now:
The RestoreDOM childList guard became unnecessary and is gone, so the patch is smaller than before. Verified on the Gboard emulator: first character composes ( |
Description
On Android, the first character typed into an empty message composer breaks IME composition. For Korean this shows up as jamo separation — typing
안녕producesㅇ안녕— and the same mechanism breaks the first character for any composing keyboard (Japanesehあ, pinyin, and GBoard English with autocorrect, wherehellobecomeshhello). Desktop is unaffected. Likely the root cause of the Android portion of #2429.This PR fixes it by patching
slate-react(viapatch-package) in the Android input path. The bug is in upstreamslate-react— present and unchanged in the latest release (0.126.0), tracked upstream as slate#5883 with a stalled fix attempt (slate#5921) — so a version bump cannot fix it today, and Cinny-side code cannot reach the affected internals.Root cause
An empty Slate leaf renders no text node on Android (
<span data-slate-zero-width><br/></span>). When the first character is composed, the browser creates a new text node inside the editable and the IME composes into it. Slate's Android input machinery then destroys that node, which makes Chromium silently cancel the composition; the next IME update starts a fresh composition session, orphaning the first jamo. Four separate code paths inslate-reactdo the destroying:scheduleAction(select)after everyinsertCompositionText(added by slate#5901, 2025-06) forces a flush on the next task (~10 ms after the first keystroke). The flush applies the pending text to the model, and the resulting re-render replaces the zero-width leaf DOM with a text-leaf DOM, removing the composing text node. This made the breakage deterministic; before #5901 the same thing happened intermittently via the 200 msFLUSH_DELAYtimer.FLUSH_DELAY) lands mid-composition whenever consecutive keystrokes are more than ~200 ms apart, with the same destructive re-render — first-character breakage for anyone typing at a relaxed pace.RestoreDOMreverts "unexpected" childList mutations on re-renders. The browser-created composing text node is exactly such a mutation, so any mid-composition re-render (e.g. thecompositionstartstate update, placeholder resize state) removes it. Upstream already skipscharacterDatamutations "because this interrupts the composition" — but childList mutations interrupt it just as much when the composition lives in a freshly created node.TextString's layout effect force-rewritesspan.textContentwhenever the DOM differs from the model. During composition the DOM legitimately runs ahead of the model (that is the entire point of the pending-diff design), so any mid-composition render replaces the text node and kills the session.The fix (5 hunks in
patches/slate-react+0.123.0.patch, all Android-gated)scheduleAction(select)from slate#5901 withEDITOR_TO_PENDING_SELECTION— the caret still ends up where #5901 intended (applied at the next flush), without forcing an immediate mid-composition flush.flush(): while the IME is composing and the pending diffs target a leaf that is still empty in the model (the only case where applying them changes DOM structure), defer the flush.handleCompositionEnd: apply such deferred diffs synchronously (ReactDOM.flushSync) at the composition boundary. The next composition session (Korean starts one per syllable, ~1 ms later) then begins inside the new text node instead of a node the upcoming render is about to replace. This also means the model is guaranteed current the moment a composition ends — pressing Enter right after the last syllable reads the full message (this was racy even before this PR).restoreDOM(): skip reverting childList mutations that contain the node the IME is currently composing in — the exact logic upstream already applies tocharacterDatamutations.TextString: skip thetextContentrewrite while the IME is composing inside that span (Android only). The pending-diff flush reconciles the model when composition ends.Hunks 1–4 are inside
useAndroidInputManager/RestoreDOM, which only run on Android user agents; hunk 5 is explicitlyIS_ANDROID-gated. Desktop behavior is untouched. The patch targetsdist/index.es.js, the only bundle Vite consumes (both dev and build); it is pinned toslate-react@0.123.0andpatch-packagefails loudly if the version is ever bumped, so it cannot silently rot. It should be dropped once upstream fixes slate#5883.Verification
Reproduced and verified with real Chromium IME plumbing driven over CDP (
Input.imeSetComposition/Input.insertText, the same code path a real IME uses), against Cinny'sCustomEditormounted standalone, with an Android UA so slate-react takes its Android input path. GBoard-style event streams, human typing pace:안녕, slow (350 ms/key)ㅇ안녕안녕안녕, fast (120 ms/key)ㅇ안녕안녕hello(composing keyboard)hhellohelloㅇ안녕(broken text)안녕(complete)안/녕in separate blocks, clean안녕안녕(unchanged)Each scenario was re-run repeatedly (no flakes). A clean
npm ciwas verified to auto-apply the patch viapostinstall.Also verified on an Android emulator with the real GBoard: Android 14 (Pixel 7 AVD, Play Store image, Chrome for Android 113), typing
안녕on the actual GBoard Korean 2-bulsik on-screen keyboard at human pace into this repro page:ㅇㅏ녕— first-character jamo separation, and GBoard's suggestion strip shows onlyㅏ녕, confirming the IME lost track of the text it was composing (the desync this PR describes).안녕, with GBoard suggestions (안녕 / 안뇽 / 안녕하세요) fully in sync.Physical-device confirmation is still welcome — the fastest check is typing Korean into the message box of a
vitedev build from an Android phone, before and after this patch.Repro harness (standalone editor page + CDP driver)
repro.html(repo root, not committed):repro-src/main.tsxmountsCustomEditorfromsrc/app/components/editor/Editorwithplaceholder="Send a message..."and logs composition/beforeinput/input events plus aMutationObservertrace of the editable towindow.__log.The driver (Node +
puppeteer-coreon desktop Chrome) sets an Android UA, then types GBoard-style:Happy to share the full driver script if useful.
Related
patch-packagedependency should be dropped.🤖 Generated with Claude Code