fix(sync): eliminate zombie focus, flush debounced state on window blur, remove stale closures and buffer ws messages
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s

This commit is contained in:
sandrodach committed 2026-10-26 22:27:11 +01:00
1 parent 06f5f7043c
commit cedf5aebff
4 files changed
+221 -59

No files matched your search

@@ -196,10 +196,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
switch (evt.type) { switch (evt.type) {
case 'doc_state': { case 'doc_state': {
if (evt.title !== undefined) { if (evt.title !== undefined) {
const isTitleFocused = const isTitleActivelyEditing =
titleInputRef.current !== null && titleInputRef.current !== null &&
document.activeElement === titleInputRef.current; document.activeElement === titleInputRef.current &&
if (!isTitleFocused) { typeof document !== 'undefined' &&
document.hasFocus();
if (!isTitleActivelyEditing) {
setTitle(evt.title); setTitle(evt.title);
} }
} }
@@ -211,10 +213,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
case 'title_updated': { case 'title_updated': {
if (evt.title !== undefined) { if (evt.title !== undefined) {
const isTitleFocused = const isTitleActivelyEditing =
titleInputRef.current !== null && titleInputRef.current !== null &&
document.activeElement === titleInputRef.current; document.activeElement === titleInputRef.current &&
if (!isTitleFocused) { typeof document !== 'undefined' &&
document.hasFocus();
if (!isTitleActivelyEditing) {
setTitle(evt.title); setTitle(evt.title);
} }
if (docRef.current) { if (docRef.current) {
@@ -407,6 +411,27 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, 250); }, 250);
}; };
const handleTitleBlur = () => {
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
}
};
// Flush title debouncing on window blur so background window never holds stale pending title
useEffect(() => {
const handleWindowBlur = () => {
if (titleDebounceRef.current !== null) {
window.clearTimeout(titleDebounceRef.current);
titleDebounceRef.current = null;
sendTitleUpdated(title);
}
};
window.addEventListener('blur', handleWindowBlur);
return () => window.removeEventListener('blur', handleWindowBlur);
}, [title, sendTitleUpdated]);
const handleUpdateBlock = useCallback( const handleUpdateBlock = useCallback(
(updatedBlock: DocumentBlock) => { (updatedBlock: DocumentBlock) => {
setBlocks((prev) => setBlocks((prev) =>
@@ -920,6 +945,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
value={title} value={title}
readOnly={!canEdit} readOnly={!canEdit}
onChange={handleTitleChange} onChange={handleTitleChange}
onBlur={handleTitleBlur}
placeholder="Titel der Notiz…" placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight" className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
/> />
@@ -102,15 +102,87 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const localContentRef = useRef<string>(localContent); const localContentRef = useRef<string>(localContent);
localContentRef.current = localContent; localContentRef.current = localContent;
// Sync incoming remote changes ONLY when this textarea is NOT active // Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
const onUpdateBlockRef = useRef(onUpdateBlock);
onUpdateBlockRef.current = onUpdateBlock;
const onFocusBlockRef = useRef(onFocusBlock);
onFocusBlockRef.current = onFocusBlock;
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
onCreateBlockBelowRef.current = onCreateBlockBelow;
const onDeleteBlockRef = useRef(onDeleteBlock);
onDeleteBlockRef.current = onDeleteBlock;
const onIndentBlockRef = useRef(onIndentBlock);
onIndentBlockRef.current = onIndentBlock;
const onNavigateFocusRef = useRef(onNavigateFocus);
onNavigateFocusRef.current = onNavigateFocus;
const sendCursorMovedRef = useRef(sendCursorMoved);
sendCursorMovedRef.current = sendCursorMoved;
// Track if this input currently has actual focus in an active browser window
const isFocusedRef = useRef<boolean>(false);
const flushDebounce = useCallback(
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const nextBlock: DocumentBlock = {
...latestBlockRef.current,
content: contentToFlush,
...overrides,
};
onUpdateBlockRef.current(nextBlock, true);
},
[]
);
// Sync incoming remote changes ONLY when this textarea is NOT actively being edited
// Condition: isFocusedRef.current === true && document.hasFocus()
useEffect(() => { useEffect(() => {
const isActivelyFocused = const isActivelyEditing =
textareaRef.current !== null && document.activeElement === textareaRef.current; isFocusedRef.current === true &&
if (!isActivelyFocused && block.content !== localContentRef.current) { typeof document !== 'undefined' &&
document.hasFocus() &&
textareaRef.current !== null &&
document.activeElement === textareaRef.current;
if (!isActivelyEditing && block.content !== localContentRef.current) {
setLocalContent(block.content || ''); setLocalContent(block.content || '');
localContentRef.current = block.content || '';
} }
}, [block.content]); }, [block.content]);
// Handle window blur / focus to instantly flush debounced input and clear zombie focus
useEffect(() => {
const handleWindowBlur = () => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
flushDebounce(localContentRef.current);
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
}
isFocusedRef.current = false;
sendCursorMovedRef.current?.(null);
};
const handleWindowFocus = () => {
const isThisActive =
textareaRef.current !== null &&
document.activeElement === textareaRef.current &&
document.hasFocus();
isFocusedRef.current = isThisActive;
};
window.addEventListener('blur', handleWindowBlur);
window.addEventListener('focus', handleWindowFocus);
return () => {
window.removeEventListener('blur', handleWindowBlur);
window.removeEventListener('focus', handleWindowFocus);
};
}, [flushDebounce]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (debounceTimerRef.current !== null) { if (debounceTimerRef.current !== null) {
@@ -136,22 +208,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return () => window.removeEventListener('resize', adjustHeight); return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]); }, [adjustHeight]);
const flushDebounce = useCallback(
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const nextBlock: DocumentBlock = {
...latestBlockRef.current,
content: contentToFlush,
...overrides,
};
onUpdateBlock(nextBlock, true);
},
[onUpdateBlock]
);
const filteredSlashCommands = useMemo(() => { const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return []; if (slashSearch === null) return [];
const q = slashSearch.toLowerCase(); const q = slashSearch.toLowerCase();
@@ -237,7 +293,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = window.setTimeout(() => {
debounceTimerRef.current = null; debounceTimerRef.current = null;
onUpdateBlock({ onUpdateBlockRef.current({
...latestBlockRef.current, ...latestBlockRef.current,
content: val, content: val,
}); });
@@ -245,28 +301,34 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}; };
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => { const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
onFocusBlock(block.id); isFocusedRef.current = true;
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd); onFocusBlockRef.current(block.id);
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
}; };
const handleBlur = () => { const handleBlur = () => {
isFocusedRef.current = false;
if (debounceTimerRef.current !== null) { if (debounceTimerRef.current !== null) {
flushDebounce(localContent); window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
flushDebounce(localContentRef.current);
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
} }
sendCursorMoved?.(null); sendCursorMovedRef.current?.(null);
}; };
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => { const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
}; };
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
}; };
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => { const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget; const target = e.currentTarget;
sendCursorMoved?.(block.id, target.selectionStart, target.selectionEnd); sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
}; };
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null; const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
@@ -306,7 +368,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
flushDebounce(localContent); flushDebounce(localContent);
onIndentBlock(block.id, e.shiftKey ? -1 : 1); onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1);
return; return;
} }
@@ -316,7 +378,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
e.stopPropagation(); e.stopPropagation();
setSlashSearch(null); setSlashSearch(null);
flushDebounce(localContent); flushDebounce(localContent);
onCreateBlockBelow(block.id, localContent); onCreateBlockBelowRef.current(block.id, localContent);
return; return;
} }
@@ -330,7 +392,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
window.clearTimeout(debounceTimerRef.current); window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null; debounceTimerRef.current = null;
} }
onDeleteBlock(block.id); onDeleteBlockRef.current(block.id);
return; return;
} }
@@ -340,7 +402,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (el && el.selectionStart === 0 && el.selectionEnd === 0) { if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
e.preventDefault(); e.preventDefault();
flushDebounce(localContent); flushDebounce(localContent);
onNavigateFocus(block.id, 'up'); onNavigateFocusRef.current(block.id, 'up');
} }
return; return;
} }
@@ -350,7 +412,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) { if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
e.preventDefault(); e.preventDefault();
flushDebounce(localContent); flushDebounce(localContent);
onNavigateFocus(block.id, 'down'); onNavigateFocusRef.current(block.id, 'down');
} }
return; return;
} }
@@ -742,7 +804,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{canEdit && ( {canEdit && (
<button <button
type="button" type="button"
onClick={() => onDeleteBlock(block.id)} onClick={() => onDeleteBlockRef.current(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0" className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
title="Block löschen" title="Block löschen"
> >
+70 -19
View File
@@ -84,6 +84,13 @@ export function useDocumentSync(
const onRemoteEventRef = useRef(onRemoteEvent); const onRemoteEventRef = useRef(onRemoteEvent);
onRemoteEventRef.current = onRemoteEvent; onRemoteEventRef.current = onRemoteEvent;
const docIdRef = useRef(docId);
docIdRef.current = docId;
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
const currentUserRef = useRef(currentUser);
currentUserRef.current = currentUser;
const reconnectTimerRef = useRef<number | null>(null); const reconnectTimerRef = useRef<number | null>(null);
const fallbackTimerRef = useRef<number | null>(null); const fallbackTimerRef = useRef<number | null>(null);
@@ -93,6 +100,12 @@ export function useDocumentSync(
() => getUserColor(currentUser?.id || clientIdRef.current), () => getUserColor(currentUser?.id || clientIdRef.current),
[currentUser?.id] [currentUser?.id]
); );
const localUserNameRef = useRef(localUserName);
localUserNameRef.current = localUserName;
const localUserColorRef = useRef(localUserColor);
localUserColorRef.current = localUserColor;
const pendingMessagesQueueRef = useRef<string[]>([]);
const lastCursorSendRef = useRef<number>(0); const lastCursorSendRef = useRef<number>(0);
const pendingCursorTimerRef = useRef<number | null>(null); const pendingCursorTimerRef = useRef<number | null>(null);
@@ -126,9 +139,11 @@ export function useDocumentSync(
if (!docId) { if (!docId) {
setStatus('offline'); setStatus('offline');
setRemoteCursors({}); setRemoteCursors({});
pendingMessagesQueueRef.current = [];
return; return;
} }
pendingMessagesQueueRef.current = [];
let isDisposed = false; let isDisposed = false;
let retryCount = 0; let retryCount = 0;
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`; const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
@@ -171,6 +186,19 @@ export function useDocumentSync(
} }
retryCount = 0; retryCount = 0;
setStatus('connected'); setStatus('connected');
// Flush any queued messages buffered while connecting or reconnecting
if (pendingMessagesQueueRef.current.length > 0) {
const queue = [...pendingMessagesQueueRef.current];
pendingMessagesQueueRef.current = [];
for (const msg of queue) {
try {
ws.send(msg);
} catch (err) {
console.warn('[DocSync] Failed to flush queued message:', err);
}
}
}
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -281,27 +309,32 @@ export function useDocumentSync(
const executeSendCursor = useCallback( const executeSendCursor = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => { (blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
const ws = wsRef.current; const ws = wsRef.current;
if (!docId || !ws || ws.readyState !== WebSocket.OPEN) return; const activeDocId = docIdRef.current;
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = { const payload: DocSyncEvent = {
type: 'cursor_moved', type: 'cursor_moved',
docId, docId: activeDocId,
clientId: clientIdRef.current, clientId: clientIdRef.current,
userId: currentUser?.id, userId: currentUserRef.current?.id,
userName: localUserName, userName: localUserNameRef.current,
userColor: localUserColor, userColor: localUserColorRef.current,
blockId: blockId || undefined, blockId: blockId || undefined,
selectionStart, selectionStart,
selectionEnd, selectionEnd,
}; };
ws.send(JSON.stringify(payload)); try {
ws.send(JSON.stringify(payload));
} catch {
// Ephemeral cursor, ignore send failure
}
lastCursorSendRef.current = Date.now(); lastCursorSendRef.current = Date.now();
}, },
[docId, currentUser?.id, localUserName, localUserColor] []
); );
const sendCursorMoved = useCallback( const sendCursorMoved = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => { (blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
if (!docId) return; if (!docIdRef.current) return;
const now = Date.now(); const now = Date.now();
const elapsed = now - lastCursorSendRef.current; const elapsed = now - lastCursorSendRef.current;
@@ -340,30 +373,48 @@ export function useDocumentSync(
} }
} }
}, },
[docId, executeSendCursor] [executeSendCursor]
); );
const sendRawEvent = useCallback( const sendRawEvent = useCallback(
(evt: DocSyncEvent, fallbackFn?: () => void) => { (evt: DocSyncEvent, fallbackFn?: () => void) => {
if (!docId || !canEdit) return; const activeDocId = docIdRef.current;
const activeCanEdit = canEditRef.current;
if (!activeDocId || !activeCanEdit) return;
const ws = wsRef.current; const ws = wsRef.current;
const payload: DocSyncEvent = { const payload: DocSyncEvent = {
...evt, ...evt,
docId, docId: activeDocId,
clientId: clientIdRef.current, clientId: clientIdRef.current,
}; };
const jsonStr = JSON.stringify(payload);
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload)); try {
} else if (fallbackFn) { ws.send(jsonStr);
if (fallbackTimerRef.current !== null) { } catch (err) {
window.clearTimeout(fallbackTimerRef.current); console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
pendingMessagesQueueRef.current.push(jsonStr);
}
} else {
console.warn(
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
evt.type
);
pendingMessagesQueueRef.current.push(jsonStr);
if (fallbackFn) {
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
}
fallbackTimerRef.current = window.setTimeout(() => {
fallbackFn();
}, 600);
} }
fallbackTimerRef.current = window.setTimeout(() => {
fallbackFn();
}, 600);
} }
}, },
[docId, canEdit] []
); );
const sendBlockCreated = useCallback( const sendBlockCreated = useCallback(
+23
View File
@@ -320,8 +320,10 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
targetID = evt.Block.ID targetID = evt.Block.ID
} }
if targetID != "" { if targetID != "" {
found := false
for i := range r.blocks { for i := range r.blocks {
if r.blocks[i].ID == targetID { if r.blocks[i].ID == targetID {
found = true
if evt.Block != nil { if evt.Block != nil {
r.blocks[i].Content = evt.Block.Content r.blocks[i].Content = evt.Block.Content
r.blocks[i].Level = evt.Block.Level r.blocks[i].Level = evt.Block.Level
@@ -359,6 +361,22 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
break break
} }
} }
if !found && evt.Block != nil {
newBlock := *evt.Block
newBlock.DocumentID = r.docID
if newBlock.Type == "" {
newBlock.Type = "text"
}
if newBlock.Level < 0 {
newBlock.Level = 0
}
r.blocks = append(r.blocks, newBlock)
r.blocks = normalizeRoomBlocks(r.docID, r.blocks)
bCopy := newBlock
evt.Block = &bCopy
evt.BlockID = targetID
mutated = true
}
} }
case "block_deleted": case "block_deleted":
@@ -441,6 +459,11 @@ func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
select { select {
case client.send <- msgBytes: case client.send <- msgBytes:
default: default:
slog.Warn("Doc room WS client send buffer full, dropping message",
"doc_id", r.docID,
"client_id", client.clientID,
"user_id", client.userID,
)
} }
} }
} }