Skip to content

Fix IME composition being cancelled on the first character in an empty leaf (Android) - #6096

Merged
dylans merged 5 commits into
ianstormtaylor:mainfrom
melodysdreamj:fix/android-ime-first-character-in-empty-leaf
Aug 18, 2026
Merged

Fix IME composition being cancelled on the first character in an empty leaf (Android)#6096
dylans merged 5 commits into
ianstormtaylor:mainfrom
melodysdreamj:fix/android-ime-first-character-in-empty-leaf

Conversation

@melodysdreamj

Copy link
Copy Markdown
Contributor

Fixes #5883.

On Android, composing the first character into an empty leaf cancels the composition, leaving that character behind on its own. In Korean, typing 안녕 produces ㅇ안녕 or ㅇㅏㄴ녕; in Japanese, ha produces hあ (the report in #5883); pinyin and Gboard English autocorrect break the same way. It affects the start of the editor and the start of any empty block, so on a chat-style app every message a Korean user types starts with a stray jamo.

Why it happens

An empty leaf does not render a text node — it renders ZeroWidthString, which on Android is a <span data-slate-zero-width><br/></span>. When the IME composes the first character there, the browser creates the text node, and the composition lives inside a node React does not know about. Anything that replaces that node cancels the composition, and the IME then starts a fresh session, orphaning what it had composed so far.

Four separate paths do exactly that:

  1. The flush scheduled after every insertCompositionText. Fix Android cursor jumping to word start after autocorrect #5901 added a scheduleAction that only re-selects, but scheduleAction sets actionTimeoutId = setTimeout(flush), so a flush lands on the next task — about 10 ms after the first keystroke. Flushing applies the pending diff, the leaf stops being empty, and the re-render swaps ZeroWidthString for TextString, removing the composing node. This is what made the breakage deterministic rather than timing-dependent.
  2. The FLUSH_DELAY timer. Same re-render, 200 ms after a keystroke, so it fires for anyone typing at a relaxed pace. This is the pre-Fix Android cursor jumping to word start after autocorrect #5901 version of the bug.
  3. RestoreDOM. The browser-created text node is an untracked childList mutation, so any mid-composition re-render (the compositionstart state update, a placeholder resize) reverts it away. restoreDOM already skips characterData mutations "because this interrupts the composition" — childList mutations interrupt it just as much when the composition lives in a node that was just added.
  4. TextString's layout effect. It force-rewrites textContent whenever the DOM differs from the value. During composition the DOM is supposed to run ahead of the value — that is what the pending-diff design is for — so this replaces the composing text node on any render that happens mid-composition.

The fix

  1. android-input-manager: replace Fix Android cursor jumping to word start after autocorrect #5901's scheduleAction with EDITOR_TO_PENDING_SELECTION. The caret still lands where Fix Android cursor jumping to word start after autocorrect #5901 intended (applied on the next flush) without forcing a flush onto the next task.
  2. android-input-manager: in flush, defer while IS_COMPOSING and the pending diffs target a leaf that is still empty in the value. That is the only case where applying a diff changes DOM structure; composing into a leaf that already has text only updates textContent, so it is untouched.
  3. android-input-manager: apply those deferred diffs synchronously (flushSync) in handleCompositionEnd. IMEs that compose a syllable at a time start the next composition in the same tick, so an async render would replace a node the new composition has already started in.
  4. restore-dom-manager: skip reverting childList mutations that contain the node the IME is composing in — the same rule already applied to characterData mutations.
  5. string.tsx: skip the textContent rewrite while the IME composes inside that span (Android only).

Points 1–4 are inside Android-only code paths; point 5 is explicitly IS_ANDROID-gated. Nothing changes for other platforms.

How this differs from #5921

#5921 addressed the same issue but @12joan found it fixed only the non-empty-editor case, still failed composes correctly at the start of the editor, and newly failed updates the Slate value during composition because the value stopped updating on every compositionupdate.

The deferral here is narrowed to pending diffs whose target leaf is empty in the value. In updates the Slate value during composition the caret sits after Type here: , so the leaf is not empty, no deferral happens, and the value still updates on every compositionupdate. I reproduced that test's shape (caret after existing text, value read mid-composition) and the value contains the composed text before composition ends.

I could not run 12joan/slate-android-tests directly (it drives Appium/BrowserStack), so I reproduced each of the four cases by hand against the manual suite instead. Happy to re-verify if you can run the automated suite against this branch.

Verification

Emulator, real Gboard — Pixel API 34 AVD, Android 14, Gboard 12.4.05.482060964 (the same Gboard build @12joan reported), Chrome 113, typing on the actual on-screen keyboard at human pace into /examples/android-tests:

Case before after
Empty — type 안녕 (Korean 2-bulsik) ㅇㅏㄴ녕, Gboard's suggestion strip desynced to ㅏㄴ녕 안녕, suggestions 안녕 / 안뇽 / 안녕하세요
Autocorrect — type Cant, space, then i Can't i Can't i (caret stays after the corrected word, #5901 still fixed)

Chromium composition pipeline via CDP (Input.imeSetComposition / Input.insertText under an Android UA), against /examples/plaintext emptied first, driving Gboard's exact event stream:

Case before after
Korean 안녕, 350 ms/key ㅇ안녕 안녕
Korean 안녕, 120 ms/key ㅇ안녕 안녕
Composing English hello hhello hello
First character in a block created by Enter broken correct
Value read mid-composition, caret after existing text updates updates (no regression)
Desktop UA control 안녕 안녕

yarn test:jest passes (96/96), lint:eslint and lint:prettier are clean. lint:typescript and test:mocha fail identically on main in my environment (pre-existing, unrelated to this change), with no errors in the files touched here.

Also included

A manual IME first character case in android-tests, as @12joan asked for on #5921, covering both the start of the editor and the start of an empty block, with the expected-vs-broken output spelled out.

Downstream

Found while fixing this in Cinny, a Matrix client, where it makes every Korean message start with a stray jamo: cinnyapp/cinny#3066 carries these same changes as a patch-package patch until this lands.

🤖 Generated with Claude Code

…y leaf

An empty leaf renders as a zero-width string (a <br> on Android) rather
than a text node. When an IME composes the first character into one, the
browser creates the text node itself, so every code path that replaces
the leaf's DOM mid-composition cancels the composition and leaves the
first character behind uncomposed (typing 안녕 gives ㅇ안녕).

Four paths did that: the flush scheduled after every insertCompositionText
(ianstormtaylor#5901), the FLUSH_DELAY timer, RestoreDOM undoing the browser-created
text node, and TextString rewriting textContent.

Fixes ianstormtaylor#5883

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: df2fc34

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
slate-react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0dacd796d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -1,4 +1,5 @@
import { DebouncedFunc } from 'lodash'
import { flushSync } from 'react-dom'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register flushSync for the UMD CommonJS transform

The production UMD build bundles react-dom, whose CommonJS entry point does not expose statically discoverable named exports; accordingly, config/rollup/rollup.config.js manually registers the exports used by Slate, but currently lists only findDOMNode. Adding this named import without registering flushSync causes the production build:rollup step used by prerelease to report that flushSync is not exported by react-dom, blocking release builds. Add flushSync to that namedExports['react-dom'] entry or access it through the already-supported default import.

Useful? React with 👍 / 👎.

The UMD build resolves react-dom through its CommonJS entry, whose named
exports rollup cannot discover statically, so a named import breaks
`NODE_ENV=production yarn build:rollup`. Matches how with-react.ts
already reaches unstable_batchedUpdates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@melodysdreamj

Copy link
Copy Markdown
Contributor Author

Note for anyone reading later: the Codex review thread on this PR is now collapsed as outdated, because the fix for it rewrote the exact line the comment was anchored to. Summarising it here so it is not lost.

Codex flagged (P1): import { flushSync } from 'react-dom' breaks the UMD production build, since rollup cannot statically discover named exports from react-dom's CommonJS entry and config/rollup/rollup.config.js only registers findDOMNode under namedExports.

It was right. NODE_ENV=production yarn build:rollup failed with "flushSync" is not exported by react-dom/index.js. My earlier build ran without NODE_ENV=production, which skips the UMD targets entirely, so I had missed it.

Fixed in cad63b6 by using the default import (ReactDOM.flushSync) instead of adding another name to namedExports — this matches how with-react.ts already reaches unstable_batchedUpdates and leaves the shared rollup config untouched.

After the fix: NODE_ENV=production yarn build:rollup completes, test:jest passes 96/96, eslint and prettier are clean, and the Korean composition check on the Gboard emulator still produces 안녕.

@melodysdreamj

Copy link
Copy Markdown
Contributor Author

Confirmed on a physical Android device, in a real chat app rather than a test page.

A user hitting this bug ran an instrumented build on their own phone (Korean 2-bulsik keyboard) and typed 안녕 into an empty composer. Trace, times in ms:

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

Result: 안녕.

The important part is what is absent. Between the first character at 54891 and compositionend at 56021 there is no structural DOM change — only characterData updates. The zero-width leaf survives the whole composition, so the IME keeps its session and all six jamo land in one syllable pair.

Without this change the same sequence swaps data-slate-zero-width for data-slate-string about 10 ms after the first character, which cancels the composition; the IME then opens a fresh session and the first jamo is orphaned as ㅇ안녕.

The deferral had one exit: compositionend. Slate already notes that event
is unreliable, so a stuck composition left the typed text outside the
value and re-armed a timer every 200ms. A composition that has lost focus
or gone quiet for 5s no longer holds flushes back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@melodysdreamj

Copy link
Copy Markdown
Contributor Author

Two follow-ups since the device confirmation.

Closed a hole in the deferral. The deferral had exactly one exit — compositionend — and Slate's own comment in Editable notes that event is not fired reliably in every browser. A composition that got stuck therefore left the typed text out of the value indefinitely while re-arming a 200 ms timer. The visible case is tapping a send button (no keydown, so the existing stuck-composition safeguard on keydown never runs) while the first character is still composing: the app reads the value and the character is missing. My earlier submit-race check pressed Enter, which is a keydown, so it did not cover this.

A composition now stops holding flushes back once it has lost focus, or once it has been idle for 5 s. The idle window is deliberately generous so that pausing mid-word in a long Japanese or pinyin composition is not cut short.

Coverage beyond Korean. The PR claims this fixes #5883, which was reported in Japanese, so verifying only Korean was not enough. On the same Pixel/Android 14/Gboard emulator, into an empty leaf:

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 た゛ as two characters instead of . For pinyin, a break after n would have restarted composition at i and offered candidates for i alone.

test:jest still passes 96/96, eslint and prettier are clean, and both the dev and NODE_ENV=production rollup builds complete.

Replacing scheduleAction with a pending selection made the caret lag: the
value moved on before the selection did, so after committing a syllable
and pressing space the caret sat a character behind for ~200ms before
snapping forward. The flush gate already stops that scheduled flush from
landing mid-composition, so ianstormtaylor#5901's handling can stay exactly as it was.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@melodysdreamj

Copy link
Copy Markdown
Contributor Author

Dropped one hunk: replacing #5901's scheduleAction was both unnecessary and harmful.

A tester on a physical device reported that after committing a syllable and pressing space, the caret sat a character behind for a moment before jumping forward. Sampling editor.selection every 45 ms after the space shows it plainly:

caret offset over 540 ms
with the EDITOR_TO_PENDING_SELECTION swap 1 1 1 1 1 2 2 2 2 2 2 2 — wrong for ~225 ms
with #5901 left alone 2 2 2 2 2 2 2 2 2 2 2 2

That is #5901's own bug coming back, which makes sense: it fixed the caret by scheduling the selection as an action, and I had turned it into a pending selection that only lands on the next flush.

The swap was also redundant. Its purpose was to stop scheduleAction's setTimeout(flush) from landing mid-composition — but the flush gate added here already refuses that flush. Verified by re-running the whole matrix with the hunk removed: Korean (slow and fast), composing English, first character in a block created by Enter, the submit race, and the mid-composition value check all still pass, and 안녕 still composes correctly on the Gboard emulator.

So android-input-manager now only adds the flush gate, the synchronous flush at compositionend, and the staleness check; scheduleAction is untouched. test:jest 96/96, eslint and prettier clean, dev and production rollup builds both complete.

…rals

Deferring flushes kept the first character alive, but cancellation paths
were left inconsistent: backspacing the only composing character
stranded it in the DOM and could close the Android keyboard, because the
deferred text was in neither the value nor anywhere slate could delete
it from.

Root cause of that whole class: on Android an empty leaf rendered no
text node at all, so the IME composed into a node it had to create and
that no re-render could preserve. Three changes make the state coherent:

- ZeroWidthString renders the same zero-width space other platforms
  get, so composition lives in a React-owned text node and the browser
  only ever edits characterData. The wrapper keeps its
  data-slate-zero-width attributes, so selection mapping is unchanged.
- An empty insertCompositionText (the IME discarding its composition)
  drops the deferred diffs instead of applying them: the value is
  already in the desired state, and applying-then-re-deleting churned
  the DOM mid-composition.
- After a cancelled composition ends, one forced render lets RestoreDOM
  put the leaf DOM back for the next composition, and the placeholder is
  re-shown only once the composition is over.

The RestoreDOM childList guard is no longer needed - it existed for the
browser-created text node - and is reverted.

Verified on a Gboard emulator (Android 14): first character composes,
backspacing the last composing 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
Contributor Author

Found and fixed a regression in this PR during device testing, and the fix simplified the whole approach (df2fc34).

The regression: backspacing away the only composing character (type into an empty composer, press backspace) left the character in the DOM and could close the Android keyboard. The IME reports that as an empty insertCompositionText — a cancellation, not a delete — and the deferred text existed in neither the value nor anywhere slate could delete it from, so every path out of that state was wrong in some way.

The underlying problem was one level deeper than this PR originally went. On Android an empty leaf renders no text node at all (<br> only), so the first composition lives in a node the browser creates — a node no re-render can preserve, which is what made RestoreDOM/childList protection necessary in the first place.

Three changes replace it:

  1. ZeroWidthString now renders the  text node on Android too, same as every other platform. Composition then lives in a React-owned node and the browser only ever edits characterData — which restoreDOM already skips. The wrapper keeps its data-slate-zero-width attributes, so toSlatePoint/toDOMPoint are unaffected. (I could not find a reason for the Android exclusion in history; if there is a known Gboard issue with it, very interested to hear.)
  2. An empty insertCompositionText drops the deferred diffs instead of applying them. The value never contained the cancelled text, so it is already in the desired state; applying-then-re-deleting churned the DOM mid-composition, which is what closed the keyboard.
  3. When a cancelled composition ends, one forced render lets RestoreDOM reconcile whatever the IME did to the leaf's DOM, and the placeholder is only re-shown once the composition is over (a contenteditable=false element appearing next to the caret mid-composition is a known keyboard-closer).

With composition in a stable node, the restoreDOM childList guard from earlier in this PR became unnecessary and is reverted — the diff is smaller than before.

Re-verified on the Gboard emulator (Android 14, Gboard 12.4.05): first character composes (안녕), backspacing the last composing character deletes it with the keyboard staying open (3/3 runs), typing again immediately after a cancel composes cleanly, and the CDP matrix (Korean slow/fast, composing English, submit race, new-block-after-Enter, mid-composition value updates with a non-empty leaf, caret-after-space, desktop control) all passes. test:jest 96/96, eslint/prettier clean, dev and production rollup builds complete.

@dylans dylans left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @melodysdreamj !

@dylans
dylans merged commit b96028a into ianstormtaylor:main Aug 18, 2026
11 checks passed
This was referenced Aug 18, 2026
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.

Composition interrupted in empty text nodes on Android IME

2 participants