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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user