fix(realtime): eliminate cpu reconnect loop, singleton global connection & fix disappearing notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s

This commit is contained in:
sandrodach committed 2026-10-26 19:08:45 +01:00
1 parent 6343d62bab
commit f90b0d5eb9
6 files changed
+221 -294

No files matched your search

@@ -50,7 +50,7 @@ import {
Radio,
} from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, sendWebSocketMessage, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
@@ -130,14 +130,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [showIconPicker, setShowIconPicker] = useState(false);
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
// Dedicated WebSocket/SSE connection & status for live indicator
const wsRef = useRef<WebSocket | null>(null);
// Realtime connection & status for live indicator
const realtimeConnRef = useRef<any>(null);
const wsReconnectTimerRef = useRef<any>(null);
const wsReconnectDelayRef = useRef<number>(2000);
const [wsConnected, setWsConnected] = useState<boolean>(false);
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
const [wsDisconnectReason, setWsDisconnectReason] = useState<string | null>(null);
const onStatsChangeRef = useRef(onStatsChange);
onStatsChangeRef.current = onStatsChange;
const lastLocalEditTimestampRef = useRef<number>(0);
const isSavingToBackendRef = useRef<boolean>(false);
// Folder state (synchronized with server)
const [customFolders, setCustomFolders] = useState<string[]>([]);
@@ -441,25 +442,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
color: getCollaboratorColor(currentUser?.id || ''),
lastActive: Date.now(),
};
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload,
});
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(
JSON.stringify({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload,
})
);
} catch {}
} else {
api.sendDocumentAwareness(selectedDoc.id, payload).catch(() => {});
}
sendWebSocketMessage({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload,
});
};
const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent<any>) => {
@@ -501,6 +488,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const executeSaveToBackend = async (docToSave: Document) => {
if (isReadOnly) return;
let saveSucceeded = false;
isSavingToBackendRef.current = true;
try {
const docId = docToSave.id;
const dirtyMap = localDirtyBlocksRef.current.get(docId);
@@ -582,7 +570,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
saveSucceeded = true;
onStatsChange();
onStatsChangeRef.current();
api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {});
if (selectedTag) {
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
@@ -590,6 +578,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} catch (e: any) {
console.error('Failed to save document to backend', e);
} finally {
isSavingToBackendRef.current = false;
if (saveSucceeded) {
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
pendingSaveDocRef.current = null;
@@ -626,6 +615,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const persistDocument = async (updatedDoc: Document, immediate = false) => {
if (isReadOnly) return;
ensureLock();
lastLocalEditTimestampRef.current = Date.now();
// Record block-level delta against previous local state and server baseline
const docId = updatedDoc.id;
@@ -792,15 +782,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (prevId && prevId !== selectedDoc?.id) {
flushPendingSave();
releaseMyLock(prevId);
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: prevId });
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: prevId }));
} catch {}
} else {
api.clearDocumentAwareness(prevId).catch(() => {});
}
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: prevId });
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
crdtSessionRef.current = null;
@@ -814,39 +796,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const session = new DocumentCRDTSession(targetDoc.id);
session.initFromBlocks(targetDoc.blocks || []);
session.onLocalUpdate = (b64Update) => {
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({
event: 'CRDT_UPDATE',
docId: targetDoc.id,
payload: { update: b64Update },
});
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(
JSON.stringify({
event: 'CRDT_UPDATE',
docId: targetDoc.id,
payload: { update: b64Update },
})
);
} catch {}
} else {
api.saveDocumentCRDT(targetDoc.id, { update: b64Update }).catch(() => {});
}
sendWebSocketMessage({
event: 'CRDT_UPDATE',
docId: targetDoc.id,
payload: { update: b64Update },
});
};
session.onRemoteChange = (crdtBlocks) => {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d)));
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
if (!recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d)));
}
};
crdtSessionRef.current = session;
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({ event: 'CRDT_JOIN', docId: targetDoc.id });
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_JOIN', docId: targetDoc.id }));
} catch {}
}
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: targetDoc.id });
api
.getDocumentCRDT(targetDoc.id)
@@ -859,7 +826,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
crdtSessionRef.current.applyRemoteUpdates(res.updates);
}
const blocks = crdtSessionRef.current.getBlocks();
if (blocks.length > 0) {
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
}
@@ -884,15 +854,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return () => {
flushPendingSave();
if (currentDocIdRef.current) {
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current });
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }));
} catch {}
} else {
api.clearDocumentAwareness(currentDocIdRef.current).catch(() => {});
}
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current });
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
crdtSessionRef.current = null;
@@ -967,7 +929,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
crdtSessionRef.current.applyRemoteUpdates(payload.updates);
}
const blocks = crdtSessionRef.current.getBlocks();
if (blocks.length > 0) {
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0;
if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
}
@@ -978,7 +943,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (payload.fromUserId !== currentUserIdRef.current) {
crdtSessionRef.current.applyRemoteUpdate(payload.update);
const blocks = crdtSessionRef.current.getBlocks();
if (blocks && blocks.length > 0) {
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0;
if (blocks && blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
}
@@ -1046,7 +1014,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (created && created.id) {
recordSyncedDoc(created);
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
onStatsChange();
onStatsChangeRef.current();
}
} else if (msg.event === 'DOC_UPDATED') {
const updated = msg.payload as Document;
@@ -1089,7 +1057,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return nextDoc;
});
}
onStatsChange();
onStatsChangeRef.current();
}
} else if (msg.event === 'DOC_DELETED') {
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
@@ -1098,7 +1066,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (selectedDocIdRef.current === deletedId) {
setSelectedDoc(null);
}
onStatsChange();
onStatsChangeRef.current();
}
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setCustomFolders).catch(() => {});
@@ -1177,7 +1145,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
window.removeEventListener('mindsift:ws', handleGlobalCustomEvent);
};
}, [onStatsChange]);
}, []);
// Fallback HTTP polling when WebSocket is disconnected (e.g. corporate proxy / firewall blocking WebSockets)
useEffect(() => {
@@ -1200,7 +1168,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
// 2. Poll document content if not actively typing and document has changed on server
if (selectedDocIdRef.current && !isEditorFocusedRef.current && !pendingSaveDocRef.current) {
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 4000;
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(selectedDocIdRef.current || '')?.size || 0) > 0;
if (
selectedDocIdRef.current &&
!isEditorFocusedRef.current &&
!hasPendingSave &&
!hasDirtyBlocks &&
!recentlyEdited
) {
try {
const fresh = await api.getDocument(selectedDocIdRef.current);
if (fresh && (!selectedDoc || fresh.updatedAt !== selectedDoc.updatedAt)) {
@@ -1889,20 +1866,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
: b
);
nextBlocks.splice(afterIndex + 1, 0, newBlock);
lastLocalEditTimestampRef.current = Date.now();
persistDocument({ ...selectedDoc, blocks: nextBlocks });
setLastFocusedBlockId(newBlock.id);
setBlockCursor(newBlock.id, 0);
// Focus into the new element next tick
setTimeout(() => {
const el = blockInputRefs.current[newBlock.id];
if (el) {
el.focus();
if ('setSelectionRange' in el && typeof (el as any).value === 'string') {
(el as any).setSelectionRange(0, 0);
}
}
}, 20);
focusBlock(newBlock.id, 0);
};
// Indent block (Tab): increase level by 1 and move all descendant sub-bullets along