From cedf5aebff7236cac885d40344393dd55a457702 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 22:27:11 +0100 Subject: [PATCH] fix(sync): eliminate zombie focus, flush debounced state on window blur, remove stale closures and buffer ws messages --- .../src/features/workspace/DocumentEditor.tsx | 38 ++++- .../src/features/workspace/OutlinerBlock.tsx | 130 +++++++++++++----- frontend/src/lib/useDocumentSync.ts | 89 +++++++++--- internal/ws/docroom.go | 23 ++++ 4 files changed, 221 insertions(+), 59 deletions(-) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 66829de..1830ab9 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -196,10 +196,12 @@ export const DocumentEditor: React.FC = ({ switch (evt.type) { case 'doc_state': { if (evt.title !== undefined) { - const isTitleFocused = + const isTitleActivelyEditing = titleInputRef.current !== null && - document.activeElement === titleInputRef.current; - if (!isTitleFocused) { + document.activeElement === titleInputRef.current && + typeof document !== 'undefined' && + document.hasFocus(); + if (!isTitleActivelyEditing) { setTitle(evt.title); } } @@ -211,10 +213,12 @@ export const DocumentEditor: React.FC = ({ case 'title_updated': { if (evt.title !== undefined) { - const isTitleFocused = + const isTitleActivelyEditing = titleInputRef.current !== null && - document.activeElement === titleInputRef.current; - if (!isTitleFocused) { + document.activeElement === titleInputRef.current && + typeof document !== 'undefined' && + document.hasFocus(); + if (!isTitleActivelyEditing) { setTitle(evt.title); } if (docRef.current) { @@ -407,6 +411,27 @@ export const DocumentEditor: React.FC = ({ }, 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( (updatedBlock: DocumentBlock) => { setBlocks((prev) => @@ -920,6 +945,7 @@ export const DocumentEditor: React.FC = ({ value={title} readOnly={!canEdit} onChange={handleTitleChange} + onBlur={handleTitleBlur} 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" /> diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 03f4635..89c4da0 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -102,15 +102,87 @@ export const OutlinerBlock: React.FC = ({ const localContentRef = useRef(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(false); + + const flushDebounce = useCallback( + (contentToFlush: string, overrides?: Partial) => { + 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(() => { - const isActivelyFocused = - textareaRef.current !== null && document.activeElement === textareaRef.current; - if (!isActivelyFocused && block.content !== localContentRef.current) { + const isActivelyEditing = + isFocusedRef.current === true && + typeof document !== 'undefined' && + document.hasFocus() && + textareaRef.current !== null && + document.activeElement === textareaRef.current; + + if (!isActivelyEditing && block.content !== localContentRef.current) { setLocalContent(block.content || ''); + localContentRef.current = 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(() => { return () => { if (debounceTimerRef.current !== null) { @@ -136,22 +208,6 @@ export const OutlinerBlock: React.FC = ({ return () => window.removeEventListener('resize', adjustHeight); }, [adjustHeight]); - const flushDebounce = useCallback( - (contentToFlush: string, overrides?: Partial) => { - 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(() => { if (slashSearch === null) return []; const q = slashSearch.toLowerCase(); @@ -237,7 +293,7 @@ export const OutlinerBlock: React.FC = ({ debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = null; - onUpdateBlock({ + onUpdateBlockRef.current({ ...latestBlockRef.current, content: val, }); @@ -245,28 +301,34 @@ export const OutlinerBlock: React.FC = ({ }; const handleFocus = (e: React.FocusEvent) => { - onFocusBlock(block.id); - sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd); + isFocusedRef.current = true; + onFocusBlockRef.current(block.id); + sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd); }; const handleBlur = () => { + isFocusedRef.current = false; 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) => { - sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); + sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); }; const handleKeyUp = (e: React.KeyboardEvent) => { - sendCursorMoved?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); + sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); }; const handleSelect = (e: React.SyntheticEvent) => { 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; @@ -306,7 +368,7 @@ export const OutlinerBlock: React.FC = ({ e.preventDefault(); e.stopPropagation(); flushDebounce(localContent); - onIndentBlock(block.id, e.shiftKey ? -1 : 1); + onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1); return; } @@ -316,7 +378,7 @@ export const OutlinerBlock: React.FC = ({ e.stopPropagation(); setSlashSearch(null); flushDebounce(localContent); - onCreateBlockBelow(block.id, localContent); + onCreateBlockBelowRef.current(block.id, localContent); return; } @@ -330,7 +392,7 @@ export const OutlinerBlock: React.FC = ({ window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } - onDeleteBlock(block.id); + onDeleteBlockRef.current(block.id); return; } @@ -340,7 +402,7 @@ export const OutlinerBlock: React.FC = ({ if (el && el.selectionStart === 0 && el.selectionEnd === 0) { e.preventDefault(); flushDebounce(localContent); - onNavigateFocus(block.id, 'up'); + onNavigateFocusRef.current(block.id, 'up'); } return; } @@ -350,7 +412,7 @@ export const OutlinerBlock: React.FC = ({ if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) { e.preventDefault(); flushDebounce(localContent); - onNavigateFocus(block.id, 'down'); + onNavigateFocusRef.current(block.id, 'down'); } return; } @@ -742,7 +804,7 @@ export const OutlinerBlock: React.FC = ({ {canEdit && (