fix(frontend): skip cursor tracking effect during deferred cursor updates

Root cause: when onCategoryEnter scheduled a deferred setCursorOffset,
the tracking effect ran BEFORE setTimeout and read the OLD DOM cursor
position (still "@a" → position 2), overwriting the new cursor position
of 4 in a subsequent render.

Fix: add skipCursorTrackingRef flag. Set it before setTimeout fires,
clear it when setTimeout executes. The tracking effect checks the flag
and skips its update during the flush window.

Same fix applied to insertCategory imperative handle.
This commit is contained in:
ZhenYi 2026-04-18 11:27:37 +08:00
parent fb09553b79
commit 126ffda4fe

View File

@ -83,7 +83,11 @@ const ChatInputArea = memo(function ChatInputArea({
// Track DOM cursor offset → ms.cursorOffset on every render // Track DOM cursor offset → ms.cursorOffset on every render
const prevCursorRef = useRef(ms.cursorOffset); const prevCursorRef = useRef(ms.cursorOffset);
// Set when a deferred cursor update is scheduled; cleared when it fires.
// Prevents the tracking effect from overwriting a programmatically-set cursor.
const skipCursorTrackingRef = useRef(false);
useEffect(() => { useEffect(() => {
if (skipCursorTrackingRef.current) return;
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
const sel = window.getSelection(); const sel = window.getSelection();
@ -131,7 +135,9 @@ const ChatInputArea = memo(function ChatInputArea({
const newCursorPos = startPos + 1 + category.length + 1; const newCursorPos = startPos + 1 + category.length + 1;
onDraftChangeRef.current(newValue); onDraftChangeRef.current(newValue);
ms.setValue(newValue); ms.setValue(newValue);
skipCursorTrackingRef.current = true;
setTimeout(() => { setTimeout(() => {
skipCursorTrackingRef.current = false;
ms.setCursorOffset(newCursorPos); ms.setCursorOffset(newCursorPos);
ms.setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(/@([^:@\s]*)(:([^\s]*))?$/)); ms.setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(/@([^:@\s]*)(:([^\s]*))?$/));
}, 0); }, 0);
@ -249,8 +255,14 @@ const ChatInputArea = memo(function ChatInputArea({
const newCursorPos = startPos + 1 + category.length + 1; const newCursorPos = startPos + 1 + category.length + 1;
onDraftChangeRef.current(newValue); onDraftChangeRef.current(newValue);
ms.setValue(newValue); ms.setValue(newValue);
// Defer cursor update until after DOM has flushed the new mention value // Defer cursor update until after DOM has flushed the new mention value.
setTimeout(() => ms.setCursorOffset(newCursorPos), 0); // Skip tracking effect during the flush window to avoid it overwriting
// the deferred cursor with the old DOM position.
skipCursorTrackingRef.current = true;
setTimeout(() => {
skipCursorTrackingRef.current = false;
ms.setCursorOffset(newCursorPos);
}, 0);
}} }}
suggestions={ms.suggestions} suggestions={ms.suggestions}
selectedIndex={ms.selectedIndex} selectedIndex={ms.selectedIndex}