fix(frontend): fix useMemo deps syntax and replace stale ref reads in render body

useMemo received deps as trailing comma-separated args instead of array.
Replace all render-body reads of mentionStateRef.current with reactive
mentionState from useMemo. Keep ref for event listener closures only.
This commit is contained in:
ZhenYi 2026-04-18 11:14:10 +08:00
parent 434a3595eb
commit 989a4117db

View File

@ -223,19 +223,13 @@ export function MentionPopover({
onCategoryEnterRef.current = onCategoryEnter; onCategoryEnterRef.current = onCategoryEnter;
// Parse mention state // Parse mention state
mentionStateRef.current = useMemo( const mentionState = useMemo(() => {
() => {
const before = inputValue.slice(0, cursorPosition); const before = inputValue.slice(0, cursorPosition);
const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/); const match = before.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!match) return null; if (!match) return null;
return { category: match[1].toLowerCase(), item: (match[3] ?? '').toLowerCase(), hasColon: match[2] !== undefined }; return { category: match[1].toLowerCase(), item: (match[3] ?? '').toLowerCase(), hasColon: match[2] !== undefined };
}, }, [inputValue, cursorPosition]);
// Inline — not used as dependency mentionStateRef.current = mentionState;
inputValue, cursorPosition,
);
// Force update reference
const [, setTick] = useState(0);
useEffect(() => { setTick(t => t + 1); }, [inputValue, cursorPosition]);
visibleSuggestionsRef.current = suggestions; visibleSuggestionsRef.current = suggestions;
selectedIndexRef.current = selectedIndex; selectedIndexRef.current = selectedIndex;
@ -314,7 +308,7 @@ export function MentionPopover({
useLayoutEffect(() => { useLayoutEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
if (!mentionStateRef.current) return; if (!mentionState) return;
const textarea = containerRef.current; const textarea = containerRef.current;
const styles = window.getComputedStyle(textarea); const styles = window.getComputedStyle(textarea);
@ -328,8 +322,7 @@ export function MentionPopover({
tempDiv.style.pointerEvents = 'none'; tempDiv.style.pointerEvents = 'none';
document.body.appendChild(tempDiv); document.body.appendChild(tempDiv);
const ms = mentionStateRef.current; const pattern = mentionState.hasColon ? `@${mentionState.category}:${mentionState.item}` : `@${mentionState.category}`;
const pattern = ms.hasColon ? `@${ms.category}:${ms.item}` : `@${ms.category}`;
tempDiv.textContent = inputValue.slice(0, cursorPosition - pattern.length + (inputValue.slice(0, cursorPosition).lastIndexOf(pattern))); tempDiv.textContent = inputValue.slice(0, cursorPosition - pattern.length + (inputValue.slice(0, cursorPosition).lastIndexOf(pattern)));
const span = document.createElement('span'); const span = document.createElement('span');
@ -381,7 +374,7 @@ export function MentionPopover({
// Simple approach: just use the textarea bounds + estimated offset // Simple approach: just use the textarea bounds + estimated offset
// This is sufficient for most cases // This is sufficient for most cases
}, [inputValue, cursorPosition, containerRef, mentionStateRef, suggestions.length]); }, [inputValue, cursorPosition, containerRef, mentionState, suggestions.length]);
// ─── Keyboard Navigation ─────────────────────────────────────────────────── // ─── Keyboard Navigation ───────────────────────────────────────────────────
@ -434,18 +427,16 @@ export function MentionPopover({
// Hide when mention state is gone // Hide when mention state is gone
useEffect(() => { useEffect(() => {
const ms = mentionStateRef.current; if (!mentionState) closePopover();
if (!ms) closePopover(); }, [inputValue, cursorPosition, closePopover, mentionState]);
}, [inputValue, cursorPosition, closePopover]);
// Don't render if no valid mention context // Don't render if no valid mention context
const ms = mentionStateRef.current; if (!mentionState) return null;
if (!ms) return null;
const isLoading = (ms.category === 'repository' && reposLoading) || const isLoading = (mentionState.category === 'repository' && reposLoading) ||
(ms.category === 'ai' && aiConfigsLoading); (mentionState.category === 'ai' && aiConfigsLoading);
const currentCategory = ms.hasColon ? ms.category : null; const currentCategory = mentionState.hasColon ? mentionState.category : null;
const catConfig = currentCategory ? CATEGORY_CONFIG[currentCategory] : null; const catConfig = currentCategory ? CATEGORY_CONFIG[currentCategory] : null;
return ( return (
@ -456,12 +447,12 @@ export function MentionPopover({
<span className="text-xs font-semibold text-muted-foreground">@</span> <span className="text-xs font-semibold text-muted-foreground">@</span>
</div> </div>
<div className="flex-1 flex items-center gap-1.5 min-w-0"> <div className="flex-1 flex items-center gap-1.5 min-w-0">
{ms.hasColon ? ( {mentionState.hasColon ? (
<> <>
<span className={cn('text-xs font-medium px-1.5 py-0.5 rounded-md', catConfig?.bgColor, catConfig?.color)}>{ms.category}</span> <span className={cn('text-xs font-medium px-1.5 py-0.5 rounded-md', catConfig?.bgColor, catConfig?.color)}>{mentionState.category}</span>
{ms.item && (<> {mentionState.item && (<>
<span className="text-muted-foreground">/</span> <span className="text-muted-foreground">/</span>
<span className="text-xs text-foreground font-medium truncate">{ms.item}</span> <span className="text-xs text-foreground font-medium truncate">{mentionState.item}</span>
</>)} </>)}
</> </>
) : ( ) : (
@ -488,7 +479,7 @@ export function MentionPopover({
<SuggestionItem key={s.mentionId || s.label + i} suggestion={s} isSelected={i === selectedIndex} <SuggestionItem key={s.mentionId || s.label + i} suggestion={s} isSelected={i === selectedIndex}
onSelect={() => doInsert(s)} onSelect={() => doInsert(s)}
onMouseEnter={() => { setSelectedIndex(i); selectedIndexRef.current = i; }} onMouseEnter={() => { setSelectedIndex(i); selectedIndexRef.current = i; }}
searchTerm={ms.item} /> searchTerm={mentionState.item} />
</div> </div>
))} ))}
</div> </div>