@@ -198,6 +198,17 @@ export const MultilineInput = forwardRef<
198198
199199 const stickyColumnRef = useRef < number | null > ( null )
200200
201+ // Refs to track latest value and cursor position synchronously for IME input handling.
202+ // When IME sends multiple character events rapidly (e.g., Chinese input), React batches
203+ // state updates, causing subsequent events to see stale closure values. These refs are
204+ // updated synchronously to ensure each keystroke builds on the previous one.
205+ const valueRef = useRef ( value )
206+ const cursorPositionRef = useRef ( cursorPosition )
207+
208+ // Keep refs current on every render (synchronous assignment avoids useEffect timing issues)
209+ valueRef . current = value
210+ cursorPositionRef . current = cursorPosition
211+
201212 // Helper to get or set the sticky column for vertical navigation.
202213 // When stickyColumnRef.current is set, we return it (preserving column across
203214 // multiple up/down presses). When null, we calculate from current cursor position.
@@ -337,31 +348,50 @@ export const MultilineInput = forwardRef<
337348 const selection = getSelectionRange ( )
338349 if ( selection ) {
339350 // Replace selected text with the new text
351+ clearSelection ( )
352+ // Read from refs which have the latest values (updated synchronously below)
353+ const currentValue = valueRef . current
340354 const newValue =
341- value . slice ( 0 , selection . start ) +
355+ currentValue . slice ( 0 , selection . start ) +
342356 textToInsert +
343- value . slice ( selection . end )
344- clearSelection ( )
357+ currentValue . slice ( selection . end )
358+ const newCursor = selection . start + textToInsert . length
359+
360+ // Update refs synchronously BEFORE calling onChange - critical for IME input
361+ // where multiple characters may arrive before React processes state updates
362+ valueRef . current = newValue
363+ cursorPositionRef . current = newCursor
364+
345365 onChange ( {
346366 text : newValue ,
347- cursorPosition : selection . start + textToInsert . length ,
367+ cursorPosition : newCursor ,
348368 lastEditDueToNav : false ,
349369 } )
350370 return
351371 }
352372
353373 // No selection, insert at cursor
374+ // Read from refs to get latest state (handles rapid IME input)
375+ const currentValue = valueRef . current
376+ const currentCursor = cursorPositionRef . current
354377 const newValue =
355- value . slice ( 0 , cursorPosition ) +
378+ currentValue . slice ( 0 , currentCursor ) +
356379 textToInsert +
357- value . slice ( cursorPosition )
380+ currentValue . slice ( currentCursor )
381+ const newCursor = currentCursor + textToInsert . length
382+
383+ // Update refs synchronously BEFORE calling onChange - critical for IME input
384+ // where multiple characters may arrive before React processes state updates
385+ valueRef . current = newValue
386+ cursorPositionRef . current = newCursor
387+
358388 onChange ( {
359389 text : newValue ,
360- cursorPosition : cursorPosition + textToInsert . length ,
390+ cursorPosition : newCursor ,
361391 lastEditDueToNav : false ,
362392 } )
363393 } ,
364- [ cursorPosition , onChange , value , getSelectionRange , clearSelection ] ,
394+ [ onChange , getSelectionRange , clearSelection ] ,
365395 )
366396
367397 const moveCursor = useCallback (
0 commit comments