Skip to content

fix: Broken first-character IME composition on Android (Hangul jamo separation) - #3066

Open
melodysdreamj wants to merge 4 commits into
cinnyapp:devfrom
melodysdreamj:fix/android-ime-first-character
Open

fix: Broken first-character IME composition on Android (Hangul jamo separation)#3066
melodysdreamj wants to merge 4 commits into
cinnyapp:devfrom
melodysdreamj:fix/android-ime-first-character

Conversation

@melodysdreamj

@melodysdreamj melodysdreamj commented Aug 8, 2026

Copy link
Copy Markdown

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 (Japanese hあ, pinyin, and GBoard English with autocorrect, where hello becomes hhello). Desktop is unaffected. Likely the root cause of the Android portion of #2429.

This PR fixes it by patching slate-react (via patch-package) in the Android input path. The bug is in upstream slate-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 in slate-react do the destroying:

  1. scheduleAction(select) after every insertCompositionText (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 ms FLUSH_DELAY timer.
  2. The 200 ms flush timer (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.
  3. RestoreDOM reverts "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. the compositionstart state update, placeholder resize state) removes it. Upstream already skips characterData mutations "because this interrupts the composition" — but childList mutations interrupt it just as much when the composition lives in a freshly created node.
  4. TextString's layout effect force-rewrites span.textContent whenever 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)

  1. Replace the scheduleAction(select) from slate#5901 with EDITOR_TO_PENDING_SELECTION — the caret still ends up where #5901 intended (applied at the next flush), without forcing an immediate mid-composition flush.
  2. In 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.
  3. In 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).
  4. In restoreDOM(): skip reverting childList mutations that contain the node the IME is currently composing in — the exact logic upstream already applies to characterData mutations.
  5. In TextString: skip the textContent rewrite 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 explicitly IS_ANDROID-gated. Desktop behavior is untouched. The patch targets dist/index.es.js, the only bundle Vite consumes (both dev and build); it is pinned to slate-react@0.123.0 and patch-package fails 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's CustomEditor mounted standalone, with an Android UA so slate-react takes its Android input path. GBoard-style event streams, human typing pace:

Scenario Before After
Korean 안녕, slow (350 ms/key) ㅇ안녕 안녕
Korean 안녕, fast (120 ms/key) ㅇ안녕 안녕
English hello (composing keyboard) hhello hello
Enter pressed ~5 ms after last syllable commit — model content at keydown ㅇ안녕 (broken text) 안녕 (complete)
First character in a fresh paragraph after Enter / in separate blocks, clean
Desktop UA control (same input) 안녕 안녕 (unchanged)

Each scenario was re-run repeatedly (no flakes). A clean npm ci was verified to auto-apply the patch via postinstall.

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:

  • Unpatched build: editor shows ㅇㅏ녕 — 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).
  • Patched build: editor shows a clean 안녕, with GBoard suggestions (안녕 / 안뇽 / 안녕하세요) fully in sync.

Physical-device confirmation is still welcome — the fastest check is typing Korean into the message box of a vite dev build from an Android phone, before and after this patch.

Repro harness (standalone editor page + CDP driver)

repro.html (repo root, not committed):

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Cinny Editor IME Repro</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/repro-src/main.tsx"></script>
  </body>
</html>

repro-src/main.tsx mounts CustomEditor from src/app/components/editor/Editor with placeholder="Send a message..." and logs composition/beforeinput/input events plus a MutationObserver trace of the editable to window.__log.

The driver (Node + puppeteer-core on desktop Chrome) sets an Android UA, then types GBoard-style:

// per keystroke: Input.imeSetComposition('ㅇ' → '아' → '안'),
// syllable commit: Input.insertText('안') + Input.imeSetComposition('ㄴ'), etc.

Happy to share the full driver script if useful.

Related

🤖 Generated with Claude Code

…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>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


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.

@melodysdreamj

Copy link
Copy Markdown
Author

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:

54881 compositionstart
54884 compositionupdate  ㅇ
54891 DOM+  #text"ㅇ"                                   <- browser creates the text node
54893 DOM-  <br>                                        <- inside the empty leaf
54895 text  "ㅇ"
55048 compositionupdate  아
55263 compositionupdate  안
55432 compositionupdate  안ㄴ
55588 compositionupdate  안녀
55659 compositionupdate  안녕
56021 compositionend     안녕
56034 DOM-  <span data-slate-zero-width>"안녕"           <- leaf swap happens once,
56036 DOM+  <span data-slate-string>"안녕"                  at the composition boundary

The message sent as 안녕. What matters is the gap: between the first character and compositionend there is no structural DOM change, only characterData updates, so the IME keeps its session. Unpatched, the leaf swaps about 10 ms after the first character and the composition restarts, leaving ㅇ안녕.

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>
@melodysdreamj

Copy link
Copy Markdown
Author

Two follow-ups, both now in the patch.

Closed a hole in the deferral. The deferral that keeps composition alive had exactly one exit — compositionend — and Slate's own code notes that event is not fired reliably in every browser. A stuck composition therefore left the typed text out of the editor value entirely. The case that matters for a chat client: tapping the send button (no keydown, so Slate's stuck-composition safeguard never runs) while the first character is still composing would send a message missing that text. A composition now stops holding the flush back once it loses focus, or once it has been idle for 5 s — long enough that pausing mid-word in a long Japanese or pinyin composition is not cut short.

Verified beyond Korean, on the same Pixel/Android 14/Gboard emulator, typing into an empty composer:

input result
Japanese: then the dakuten key — one character
Chinese pinyin: n, i ni as a single composing run, candidates 你 尼 泥 逆
Korean: 안녕 (re-run after this change) 안녕

The Japanese case is the decisive one: dakuten modifies the character already being composed, so a composition broken at the first character would leave た゛ rather than .

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>
@melodysdreamj

Copy link
Copy Markdown
Author

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 scheduleAction (added by slate#5901 precisely to fix Android caret placement) with a pending selection, which lands only on the next flush. Sampling editor.selection every 45 ms after the space:

caret offset over 540 ms
with that hunk 1 1 1 1 1 2 2 2 2 2 2 2 — wrong for ~225 ms
without it 2 2 2 2 2 2 2 2 2 2 2 2

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>
@melodysdreamj

Copy link
Copy Markdown
Author

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:

  • empty leaves render the same zero-width space as every other platform, so composition lives in a React-owned node;
  • a cancelled composition (empty insertCompositionText) drops the deferred text instead of applying-and-re-deleting it — the value never contained it, so nothing needs to change;
  • after the cancelled composition ends, one forced render restores the leaf DOM for the next composition.

The RestoreDOM childList guard became unnecessary and is gone, so the patch is smaller than before.

Verified on the Gboard emulator: first character composes (안녕), backspacing the last composing character deletes it with the keyboard staying open (3/3), typing again right after a cancel composes cleanly, and the full CDP regression matrix passes. Same changes upstream: ianstormtaylor/slate#6096.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant