From 0dacd796d227b733a550069b86211dcb803f9861 Mon Sep 17 00:00:00 2001 From: june lee Date: Sun, 9 Aug 2026 10:12:21 +0900 Subject: [PATCH 1/5] Fix IME composition being cancelled on the first character in an empty leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty leaf renders as a zero-width string (a
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 (#5901), the FLUSH_DELAY timer, RestoreDOM undoing the browser-created text node, and TextString rewriting textContent. Fixes #5883 Co-Authored-By: Claude Fable 5 --- .changeset/lucky-pandas-shave.md | 5 ++ .../restore-dom/restore-dom-manager.ts | 29 +++++++++- .../slate-react/src/components/string.tsx | 17 +++++- .../android-input-manager.ts | 58 ++++++++++++++++--- site/examples/js/android-tests.jsx | 20 +++++++ site/examples/ts/android-tests.tsx | 20 +++++++ 6 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 .changeset/lucky-pandas-shave.md diff --git a/.changeset/lucky-pandas-shave.md b/.changeset/lucky-pandas-shave.md new file mode 100644 index 0000000000..4b64ac972d --- /dev/null +++ b/.changeset/lucky-pandas-shave.md @@ -0,0 +1,5 @@ +--- +'slate-react': patch +--- + +Fix IME composition being cancelled on the first character typed into an empty leaf on Android, which left the first character behind as a separate, uncomposed character (typing 안녕 in Korean produced ㅇ안녕). diff --git a/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts index 469512dc0b..125a262e4e 100644 --- a/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts +++ b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts @@ -1,6 +1,6 @@ import { RefObject } from 'react' import { ReactEditor } from '../../plugin/react-editor' -import { isTrackedMutation } from 'slate-dom' +import { IS_COMPOSING, isTrackedMutation } from 'slate-dom' export type RestoreDOMManager = { registerMutations: (mutations: MutationRecord[]) => void @@ -32,6 +32,26 @@ export const createRestoreDomManager = ( function restoreDOM() { if (bufferedMutations.length > 0) { + // The node the IME is currently composing in, if any. Restoring a + // mutation that contains it removes the node out from under the + // composition and cancels it, in the same way characterData mutations do + // below. This happens when the first character is typed into an empty + // leaf, where the browser adds a text node next to the leaf's `
`. + let composingNode: Node | null = null + + if (IS_COMPOSING.get(editor)) { + try { + composingNode = + ReactEditor.getWindow(editor).getSelection()?.anchorNode ?? null + } catch { + composingNode = null + } + } + + const containsComposingNode = (node: Node) => + !!composingNode && + (node === composingNode || node.contains(composingNode)) + bufferedMutations.reverse().forEach(mutation => { if (mutation.type === 'characterData') { // We don't want to restore the DOM for characterData mutations @@ -39,6 +59,13 @@ export const createRestoreDomManager = ( return } + if ( + containsComposingNode(mutation.target) || + Array.from(mutation.addedNodes).some(containsComposingNode) + ) { + return + } + mutation.removedNodes.forEach(node => { mutation.target.insertBefore(node, mutation.nextSibling) }) diff --git a/packages/slate-react/src/components/string.tsx b/packages/slate-react/src/components/string.tsx index 5f1dce96b4..be2e36b1b7 100644 --- a/packages/slate-react/src/components/string.tsx +++ b/packages/slate-react/src/components/string.tsx @@ -3,7 +3,7 @@ import { Editor, Text, Path, Element, Node } from 'slate' import { ReactEditor, useSlateStatic } from '..' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' -import { IS_ANDROID } from 'slate-dom' +import { IS_ANDROID, IS_COMPOSING } from 'slate-dom' import { MARK_PLACEHOLDER_SYMBOL } from 'slate-dom' /** @@ -61,6 +61,7 @@ const String = (props: { */ const TextString = (props: { text: string; isTrailing?: boolean }) => { const { text, isTrailing = false } = props + const editor = useSlateStatic() const ref = useRef(null) const getTextContent = () => { return `${text ?? ''}${isTrailing ? '\n' : ''}` @@ -81,6 +82,20 @@ const TextString = (props: { text: string; isTrailing?: boolean }) => { const textWithTrailing = getTextContent() if (ref.current && ref.current.textContent !== textWithTrailing) { + // On Android the DOM intentionally runs ahead of the value while the IME + // composes, since input is buffered as pending diffs. Writing + // `textContent` here would replace the text node the composition lives in + // and cancel it; the pending diffs reconcile this span when the + // composition ends. + if (IS_ANDROID && IS_COMPOSING.get(editor)) { + const composingNode = + ReactEditor.getWindow(editor).getSelection()?.anchorNode + + if (composingNode && ref.current.contains(composingNode)) { + return + } + } + ref.current.textContent = textWithTrailing } diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index 1bc8041c16..f07042a3b5 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -1,4 +1,5 @@ import { DebouncedFunc } from 'lodash' +import { flushSync } from 'react-dom' import { Editor, Location, Node, Path, Point, Range, Transforms } from 'slate' import { ReactEditor } from '../../plugin/react-editor' import { @@ -123,6 +124,22 @@ export function createAndroidInputManager({ action.run() } + // A leaf that Slate models as empty renders as a zero-width string, which on + // Android is a `
` rather than a text node. When the IME composes the + // first character into such a leaf, the browser creates a text node for the + // composition. Applying the pending diffs re-renders that leaf as a text + // leaf, which unmounts the zero-width span and takes the text node the IME is + // composing in with it, silently cancelling the composition. + const hasPendingDiffsInEmptyLeaf = () => + !!EDITOR_TO_PENDING_DIFFS.get(editor)?.some(({ path }) => { + try { + return Node.leaf(editor, path).text.length === 0 + } catch { + // The path may no longer resolve if the editor changed underneath us. + return false + } + }) + const flush = () => { if (flushTimeoutId) { clearTimeout(flushTimeoutId) @@ -134,6 +151,17 @@ export function createAndroidInputManager({ actionTimeoutId = null } + // Defer flushing until the composition ends, so that the re-render that + // would replace the composing text node cannot happen mid-composition. + // Composing into a leaf that already has text is unaffected: applying the + // diff there only updates `textContent`, so the value still updates on + // every `compositionupdate`. + if (IS_COMPOSING.get(editor) && hasPendingDiffsInEmptyLeaf()) { + debug('deferring flush during composition in empty leaf') + flushTimeoutId = setTimeout(flush, FLUSH_DELAY) + return + } + if (!hasPendingDiffs() && !hasPendingAction()) { applyPendingSelection() return @@ -253,6 +281,18 @@ export function createAndroidInputManager({ clearTimeout(compositionEndTimeoutId) } + // Diffs deferred by `flush` have to be applied synchronously here. IMEs + // that compose one syllable at a time (Hangul, kana) start the next + // composition in the same tick as this event, so an asynchronous render + // would replace the text node that composition has already started in. + if (hasPendingDiffsInEmptyLeaf() && !flushing) { + flushSync(() => { + IS_COMPOSING.set(editor, false) + flush() + }) + return + } + compositionEndTimeoutId = setTimeout(() => { IS_COMPOSING.set(editor, false) flush() @@ -698,15 +738,15 @@ export function createAndroidInputManager({ offset: start.offset + text.length, } - scheduleAction( - () => { - Transforms.select(editor, { - anchor: newPoint, - focus: newPoint, - }) - }, - { at: newPoint } - ) + // Storing the selection as pending applies it on the next flush, + // like `scheduleAction` did, without forcing that flush to happen + // on the next task. Forcing it re-rendered the editor between two + // `compositionupdate` events, which is what broke composition of + // the first character in a leaf. + EDITOR_TO_PENDING_SELECTION.set(editor, { + anchor: newPoint, + focus: newPoint, + }) } return } diff --git a/site/examples/js/android-tests.jsx b/site/examples/js/android-tests.jsx index bdb956cb5b..c653e8ce1a 100644 --- a/site/examples/js/android-tests.jsx +++ b/site/examples/js/android-tests.jsx @@ -185,6 +185,26 @@ const TEST_CASES = [ }, ], }, + { + id: 'ime-first-character', + name: 'IME first character', + instructions: + 'Using a keyboard that composes (Korean, Japanese or pinyin), type a word into the empty first paragraph, then into the empty paragraph after "Second block". Every character must compose into the word you typed. If composition breaks, the first character is left behind on its own: typing 안녕 in Korean gives ㅇ안녕 or ㅇㅏㄴ녕.', + value: [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + { + type: 'paragraph', + children: [{ text: 'Second block', bold: true }], + }, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ], + }, ] const AndroidTestsExample = () => { const [testId, setTestId] = useState( diff --git a/site/examples/ts/android-tests.tsx b/site/examples/ts/android-tests.tsx index a74ae1c3ae..962a7fed04 100644 --- a/site/examples/ts/android-tests.tsx +++ b/site/examples/ts/android-tests.tsx @@ -192,6 +192,26 @@ const TEST_CASES: AndroidTestCase[] = [ }, ], }, + { + id: 'ime-first-character', + name: 'IME first character', + instructions: + 'Using a keyboard that composes (Korean, Japanese or pinyin), type a word into the empty first paragraph, then into the empty paragraph after "Second block". Every character must compose into the word you typed. If composition breaks, the first character is left behind on its own: typing 안녕 in Korean gives ㅇ안녕 or ㅇㅏㄴ녕.', + value: [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + { + type: 'paragraph', + children: [{ text: 'Second block', bold: true }], + }, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ], + }, ] const AndroidTestsExample = () => { From cad63b60c376070f525c56669bdd5f70cdfdab10 Mon Sep 17 00:00:00 2001 From: june lee Date: Sun, 9 Aug 2026 10:36:29 +0900 Subject: [PATCH 2/5] Use the react-dom default import for flushSync 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 --- .../hooks/android-input-manager/android-input-manager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index f07042a3b5..0f51e04a92 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -1,5 +1,7 @@ import { DebouncedFunc } from 'lodash' -import { flushSync } from 'react-dom' +// Default import: the UMD build resolves react-dom through its CommonJS entry, +// whose named exports rollup cannot discover statically. +import ReactDOM from 'react-dom' import { Editor, Location, Node, Path, Point, Range, Transforms } from 'slate' import { ReactEditor } from '../../plugin/react-editor' import { @@ -286,7 +288,7 @@ export function createAndroidInputManager({ // composition in the same tick as this event, so an asynchronous render // would replace the text node that composition has already started in. if (hasPendingDiffsInEmptyLeaf() && !flushing) { - flushSync(() => { + ReactDOM.flushSync(() => { IS_COMPOSING.set(editor, false) flush() }) From f9741115998bc1ddc196642338072cf50ca892d6 Mon Sep 17 00:00:00 2001 From: june lee Date: Sun, 9 Aug 2026 15:49:45 +0900 Subject: [PATCH 3/5] Stop a composition that never ends from stranding the value 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 --- .../android-input-manager.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index 0f51e04a92..15ff1f4836 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -37,6 +37,11 @@ const RESOLVE_DELAY = 25 // Time with no user interaction before the current user action is considered as done. const FLUSH_DELAY = 200 +// Time with no composition input after which a composition that never ended is +// treated as stale. Long enough that pausing mid-word does not cut a live +// composition short. +const COMPOSITION_IDLE_TIMEOUT = 5000 + // Replace with `const debug = console.log` to debug const debug = (..._: unknown[]) => {} @@ -81,6 +86,7 @@ export function createAndroidInputManager({ let compositionEndTimeoutId: ReturnType | null = null let flushTimeoutId: ReturnType | null = null let actionTimeoutId: ReturnType | null = null + let lastCompositionActivity = 0 let idCounter = 0 let insertPositionHint: StringDiff | null | false = false @@ -126,6 +132,29 @@ export function createAndroidInputManager({ action.run() } + // `compositionend` is not fired reliably in every browser, which Slate + // already works around on keydown. Deferring flushes on a composition that + // never ends would strand the typed text outside the value, so a composition + // that has gone quiet, or that has lost focus, no longer holds flushes back. + const isCompositionLive = () => { + if (!IS_COMPOSING.get(editor)) { + return false + } + + try { + const editable = ReactEditor.toDOMNode(editor, editor) + const { activeElement } = ReactEditor.getWindow(editor).document + + if (activeElement !== editable && !editable.contains(activeElement)) { + return false + } + } catch { + // Fall back to the idle check below if the editable can't be resolved. + } + + return Date.now() - lastCompositionActivity < COMPOSITION_IDLE_TIMEOUT + } + // A leaf that Slate models as empty renders as a zero-width string, which on // Android is a `
` rather than a text node. When the IME composes the // first character into such a leaf, the browser creates a text node for the @@ -158,7 +187,7 @@ export function createAndroidInputManager({ // Composing into a leaf that already has text is unaffected: applying the // diff there only updates `textContent`, so the value still updates on // every `compositionupdate`. - if (IS_COMPOSING.get(editor) && hasPendingDiffsInEmptyLeaf()) { + if (isCompositionLive() && hasPendingDiffsInEmptyLeaf()) { debug('deferring flush during composition in empty leaf') flushTimeoutId = setTimeout(flush, FLUSH_DELAY) return @@ -307,6 +336,7 @@ export function createAndroidInputManager({ debug('composition start') IS_COMPOSING.set(editor, true) + lastCompositionActivity = Date.now() if (compositionEndTimeoutId) { clearTimeout(compositionEndTimeoutId) @@ -393,6 +423,11 @@ export function createAndroidInputManager({ } const { inputType: type } = event + + if (type === 'insertCompositionText' || type === 'deleteCompositionText') { + lastCompositionActivity = Date.now() + } + let targetRange: Range | null = null const data: DataTransfer | string | undefined = (event as any).dataTransfer || event.data || undefined From b3a0fa173722ec4a96c5ea3c7a861e6760292441 Mon Sep 17 00:00:00 2001 From: june lee Date: Sun, 9 Aug 2026 16:14:17 +0900 Subject: [PATCH 4/5] Keep #5901's caret handling instead of replacing it 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 #5901's handling can stay exactly as it was. Co-Authored-By: Claude Fable 5 --- .../android-input-manager.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index 15ff1f4836..ddf7e32728 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -42,6 +42,11 @@ const FLUSH_DELAY = 200 // composition short. const COMPOSITION_IDLE_TIMEOUT = 5000 +// How often a deferred flush re-checks whether the composition is still live. +// Only bounds how quickly the value catches up with a composition that ended +// without firing `compositionend`; one that ends normally flushes immediately. +const COMPOSITION_RECHECK_DELAY = 50 + // Replace with `const debug = console.log` to debug const debug = (..._: unknown[]) => {} @@ -189,7 +194,7 @@ export function createAndroidInputManager({ // every `compositionupdate`. if (isCompositionLive() && hasPendingDiffsInEmptyLeaf()) { debug('deferring flush during composition in empty leaf') - flushTimeoutId = setTimeout(flush, FLUSH_DELAY) + flushTimeoutId = setTimeout(flush, COMPOSITION_RECHECK_DELAY) return } @@ -775,15 +780,15 @@ export function createAndroidInputManager({ offset: start.offset + text.length, } - // Storing the selection as pending applies it on the next flush, - // like `scheduleAction` did, without forcing that flush to happen - // on the next task. Forcing it re-rendered the editor between two - // `compositionupdate` events, which is what broke composition of - // the first character in a leaf. - EDITOR_TO_PENDING_SELECTION.set(editor, { - anchor: newPoint, - focus: newPoint, - }) + scheduleAction( + () => { + Transforms.select(editor, { + anchor: newPoint, + focus: newPoint, + }) + }, + { at: newPoint } + ) } return } From df2fc346afd3755f10465f1aa61c776a32b8ac0e Mon Sep 17 00:00:00 2001 From: june lee Date: Mon, 10 Aug 2026 05:42:01 +0900 Subject: [PATCH 5/5] Give empty leaves a stable text node and make cancellation drop deferrals 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 --- .../restore-dom/restore-dom-manager.ts | 29 +------------ .../slate-react/src/components/string.tsx | 8 +++- .../android-input-manager.ts | 41 ++++++++++++++++++- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts index 125a262e4e..469512dc0b 100644 --- a/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts +++ b/packages/slate-react/src/components/restore-dom/restore-dom-manager.ts @@ -1,6 +1,6 @@ import { RefObject } from 'react' import { ReactEditor } from '../../plugin/react-editor' -import { IS_COMPOSING, isTrackedMutation } from 'slate-dom' +import { isTrackedMutation } from 'slate-dom' export type RestoreDOMManager = { registerMutations: (mutations: MutationRecord[]) => void @@ -32,26 +32,6 @@ export const createRestoreDomManager = ( function restoreDOM() { if (bufferedMutations.length > 0) { - // The node the IME is currently composing in, if any. Restoring a - // mutation that contains it removes the node out from under the - // composition and cancels it, in the same way characterData mutations do - // below. This happens when the first character is typed into an empty - // leaf, where the browser adds a text node next to the leaf's `
`. - let composingNode: Node | null = null - - if (IS_COMPOSING.get(editor)) { - try { - composingNode = - ReactEditor.getWindow(editor).getSelection()?.anchorNode ?? null - } catch { - composingNode = null - } - } - - const containsComposingNode = (node: Node) => - !!composingNode && - (node === composingNode || node.contains(composingNode)) - bufferedMutations.reverse().forEach(mutation => { if (mutation.type === 'characterData') { // We don't want to restore the DOM for characterData mutations @@ -59,13 +39,6 @@ export const createRestoreDomManager = ( return } - if ( - containsComposingNode(mutation.target) || - Array.from(mutation.addedNodes).some(containsComposingNode) - ) { - return - } - mutation.removedNodes.forEach(node => { mutation.target.insertBefore(node, mutation.nextSibling) }) diff --git a/packages/slate-react/src/components/string.tsx b/packages/slate-react/src/components/string.tsx index be2e36b1b7..01e96857a4 100644 --- a/packages/slate-react/src/components/string.tsx +++ b/packages/slate-react/src/components/string.tsx @@ -150,9 +150,15 @@ export const ZeroWidthString = (props: { // be because accepting an IME suggestion when at the start of a block (no // preceding \uFEFF) removes one or more DOM elements that `toSlateRange` // depends on. (https://github.com/ianstormtaylor/slate/issues/5703) + // A leaf with no text node at all forces the IME to create one when the + // first character is composed there, and no re-render can then touch the + // leaf without cancelling the composition. Rendering the zero-width space on + // Android too gives the composition a React-owned text node to live in; the + // wrapper keeps its data-slate-zero-width attributes, so selection mapping + // is unchanged. return ( - {!IS_ANDROID || !isLineBreak ? '\uFEFF' : null} + {'\uFEFF'} {isLineBreak ?
: null}
) diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index ddf7e32728..62130f1cfd 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -92,6 +92,7 @@ export function createAndroidInputManager({ let flushTimeoutId: ReturnType | null = null let actionTimeoutId: ReturnType | null = null let lastCompositionActivity = 0 + let compositionCleared = false let idCounter = 0 let insertPositionHint: StringDiff | null | false = false @@ -142,7 +143,7 @@ export function createAndroidInputManager({ // never ends would strand the typed text outside the value, so a composition // that has gone quiet, or that has lost focus, no longer holds flushes back. const isCompositionLive = () => { - if (!IS_COMPOSING.get(editor)) { + if (!IS_COMPOSING.get(editor) || compositionCleared) { return false } @@ -326,12 +327,22 @@ export function createAndroidInputManager({ IS_COMPOSING.set(editor, false) flush() }) + updatePlaceholderVisibility() return } compositionEndTimeoutId = setTimeout(() => { IS_COMPOSING.set(editor, false) flush() + + if (compositionCleared) { + // A cancelled composition can take the empty leaf's text node with + // it. A forced render lets RestoreDOM put the leaf's DOM back, so the + // next composition starts from a clean state instead of a bare
. + EDITOR_TO_FORCE_RENDER.get(editor)?.() + } + + updatePlaceholderVisibility() }, RESOLVE_DELAY) } @@ -342,6 +353,7 @@ export function createAndroidInputManager({ IS_COMPOSING.set(editor, true) lastCompositionActivity = Date.now() + compositionCleared = false if (compositionEndTimeoutId) { clearTimeout(compositionEndTimeoutId) @@ -431,6 +443,33 @@ export function createAndroidInputManager({ if (type === 'insertCompositionText' || type === 'deleteCompositionText') { lastCompositionActivity = Date.now() + + if (event.data) { + compositionCleared = false + } else { + // The IME threw away what it was composing. The deferred text never + // reached the value, so the value is already in the desired state - + // drop the deferral instead of applying and re-deleting it, which + // would churn the DOM mid-composition and can make Android close the + // keyboard. + compositionCleared = true + const deferredDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) + + if (deferredDiffs?.length) { + const allInEmptyLeaves = deferredDiffs.every(({ path }) => { + try { + return Node.leaf(editor, path).text.length === 0 + } catch { + return false + } + }) + + if (allInEmptyLeaves) { + EDITOR_TO_PENDING_DIFFS.set(editor, []) + EDITOR_TO_PENDING_SELECTION.delete(editor) + } + } + } } let targetRange: Range | null = null