RealBlocks / client /src /hooks /useWebSocket.ts
SafeSight's picture
Add end-to-end logging for block_state_sync persistence
de7a2cd
Raw
History Blame Contribute Delete
21.4 kB
import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuthStore } from '../store/authStore';
import { useCollaborationStore, Collaborator } from '../store/collaborationStore';
import { useEditorStore } from '../store/editorStore';
import { useWsStore } from '../store/wsStore';
import {
BLOCK_STATE_SYNC,
BLOCK_STATE_SYNCED,
REQUEST_BLOCK_STATE,
COLLABORATOR_POINTER_MOVE,
COLLABORATOR_BLOCK_DRAG_START,
COLLABORATOR_BLOCK_DRAG_MOVE,
COLLABORATOR_BLOCK_DRAG_END,
COLLABORATOR_BLOCK_CONNECT,
} from '@shared/wsEvents';
let globalSocket: Socket | null = null;
// ─── Generic hook to subscribe to a WS event ───
export function useWsEvent(event: string, handler: (...args: any[]) => void) {
const socket = useWsStore((s) => s.socket);
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
if (!socket) return;
const wrapper = (...args: any[]) => handlerRef.current(...args);
socket.on(event, wrapper);
return () => {
socket.off(event, wrapper);
};
}, [socket, event]);
}
// ─── Socket lifecycle: connect on token, reconnect with stored projectId ───
export function useWebSocket() {
const token = useAuthStore((s) => s.token);
const { setConnected: setCollabConnected } = useCollaborationStore();
const { setConnected, setSocket, pushPending, shiftPending } = useWsStore();
useEffect(() => {
if (!token) {
if (globalSocket) {
globalSocket.disconnect();
globalSocket = null;
setSocket(null);
setConnected(false);
setCollabConnected(false);
}
return;
}
if (globalSocket?.connected) return;
const s = io({
auth: { token },
transports: ['websocket', 'polling'],
});
globalSocket = s;
setSocket(s);
s.on('connect', () => {
setConnected(true);
setCollabConnected(true);
// Re-join project if we have one stored
const pid = useWsStore.getState().currentProjectId;
if (pid) {
s.emit('join_project', { projectId: pid });
}
// Replay pending queue
let pending = shiftPending();
while (pending) {
s.emit(pending.action, pending.data);
pending = shiftPending();
}
});
s.on('disconnect', () => {
setConnected(false);
setCollabConnected(false);
});
s.on('collaborator_list', ({ collaborators }: { collaborators: Collaborator[] }) => {
useCollaborationStore.getState().setCollaborators(collaborators);
});
s.on('collaborator_joined', (collaborator: Collaborator) => {
useCollaborationStore.getState().addCollaborator(collaborator);
});
s.on('collaborator_left', ({ userId }: { userId: string }) => {
useCollaborationStore.getState().removeCollaborator(userId);
});
s.on('collaborator_active_file', ({ userId, fileId }: { userId: string; fileId: string | null }) => {
useCollaborationStore.getState().updateActiveFile(userId, fileId);
});
s.on('collaborator_cursor', ({ userId, fileId, cursor }: { userId: string; fileId: string; cursor: { line: number; ch: number } | null }) => {
useCollaborationStore.getState().updateCursor(userId, fileId, cursor);
});
s.on('collaborator_block_selected', ({ userId, blockId }: { userId: string; blockId: string | null }) => {
useCollaborationStore.getState().updateBlockSelection(userId, blockId);
});
s.on('collaborator_element_hover', ({ userId, elementId }: { userId: string; elementId: string | null }) => {
useCollaborationStore.getState().updateElementHover(userId, elementId);
});
s.on('block_state_synced', (data: { fileId: string; xml: string; updatedBy: string; initialBlocksXml?: Record<string, { xml: string; updatedBy: string; updatedAt: number }> }) => {
// Handle initial block state for new joiners (sent on join_project).
// server sends initialBlocksXml as { fileId: { xml, updatedBy, updatedAt } }
if (data.initialBlocksXml) {
console.log('[WS] Received initialBlocksXml with', Object.keys(data.initialBlocksXml).length, 'files');
const store = useEditorStore.getState();
for (const [fid, entry] of Object.entries(data.initialBlocksXml)) {
console.log('[WS] initialBlocksXml file:', fid, 'xmlLength:', entry.xml?.length, 'updatedBy:', entry.updatedBy);
if (entry.xml) store.setBlocksXml(fid, entry.xml);
}
if (store.activeFileId && data.initialBlocksXml[store.activeFileId]?.xml) {
console.log('[WS] setting visual elements for active file:', store.activeFileId);
store.setVisualElements(store.elementRegistry[store.activeFileId] || []);
}
return;
}
// Real-time sync update
// Note: socket.to() already excludes the sender, so no self-update check needed.
// The updatedBy filter was removed because shared auth tokens can cause
// different users to appear as the same user ID, incorrectly discarding syncs.
console.log('[WS] block_state_synced: updating blocksXml for file:', data.fileId, 'xml length:', data.xml?.length);
useEditorStore.getState().setBlocksXml(data.fileId, data.xml);
const store = useEditorStore.getState();
if (store.activeFileId === data.fileId) {
console.log('[WS] block_state_synced: file is active, setting visual elements');
store.setVisualElements(store.elementRegistry[data.fileId] || []);
} else {
console.log('[WS] block_state_synced: file is NOT active (activeFileId:', store.activeFileId, ')');
}
});
s.on('collaborator_pointer_move', ({ userId, username, x, y }: { userId: string; username: string; x: number; y: number }) => {
useCollaborationStore.getState().setRemotePointer(userId, { x, y, username });
});
s.on('collaborator_block_drag_start', ({ userId, blockId, x, y }: { userId: string; blockId: string; x: number; y: number }) => {
useCollaborationStore.getState().setRemoteBlockDrag(userId, { blockId, x, y });
});
s.on('collaborator_block_drag_move', ({ userId, blockId, x, y }: { userId: string; blockId: string; x: number; y: number }) => {
useCollaborationStore.getState().updateRemoteBlockDrag(userId, { x, y });
});
s.on('collaborator_block_drag_end', ({ userId }: { userId: string }) => {
useCollaborationStore.getState().clearRemoteBlockDrag(userId);
});
s.on('collaborator_block_connect', ({ userId, childId, parentId, inputName }: { userId?: string; childId: string; parentId: string; inputName?: string }) => {
// Clear any remaining drag state for this user (connect signals drag end + snap).
// Prevents stale position updates from disconnecting the block after it was connected.
if (userId) useCollaborationStore.getState().clearRemoteBlockDrag(userId);
useCollaborationStore.getState().pushPendingBlockConnect({ childId, parentId, inputName });
});
s.on('collaborator_block_change', ({ blockId, name, value }: { blockId: string; name: string; value: any }) => {
useCollaborationStore.getState().pushPendingBlockChange({ blockId, name, value });
});
s.on('collaborator_block_create_event', ({ blockId, fileId, xml }: { blockId: string; fileId: string; xml: string }) => {
const store = useEditorStore.getState();
console.log('[WS] collaborator_block_create_event received:', { blockId, fileId, activeFileId: store.activeFileId, xmlLength: xml?.length });
// Only apply to the currently active file; otherwise the block would appear
// on the wrong file's workspace. The full XML sync (block_state_synced) will
// handle it when the collaborator switches to that file.
if (store.activeFileId === fileId) {
console.log('[WS] pushing pending block create:', blockId);
useCollaborationStore.getState().pushPendingBlockCreate({ blockId, xml });
} else {
console.log('[WS] skipping block create - fileId mismatch:', { eventFileId: fileId, activeFileId: store.activeFileId });
}
});
s.on('file_updated', ({ fileId, content }: { fileId: string; content: string }) => {
useCollaborationStore.getState().updateFile(fileId, content);
});
s.on('file_added', ({ file, parentId }: { file: any; parentId?: string }) => {
useEditorStore.getState().addFile(file, parentId);
});
s.on('file_renamed', ({ fileId, name }: { fileId: string; name: string }) => {
useEditorStore.getState().updateFile(fileId, { name });
});
s.on('file_deleted', ({ fileId }: { fileId: string }) => {
useEditorStore.getState().removeFile(fileId);
});
s.on('visual_element_added', ({ element, parentId }: { element: any; parentId?: string }) => {
useEditorStore.getState().addVisualElement(element, parentId);
});
s.on('visual_element_updated', ({ elementId, updates }: { elementId: string; updates: any }) => {
useEditorStore.getState().updateVisualElement(elementId, updates);
});
s.on('visual_element_removed', ({ elementId }: { elementId: string }) => {
useEditorStore.getState().removeVisualElement(elementId);
});
s.on('visual_elements_reordered', ({ elements }: { elements: any[] }) => {
const store = useEditorStore.getState();
store.setVisualElements(elements);
if (store.activeFileId) {
store.syncVisualElementsToRegistry(store.activeFileId);
}
});
s.on('project_saved', ({ updatedAt, savedBy }: { updatedAt: number; savedBy: string }) => {
useCollaborationStore.getState().setLastSaved(updatedAt);
useCollaborationStore.getState().setSavedBy(savedBy);
});
return () => {
const pid = useWsStore.getState().currentProjectId;
if (pid) {
s.emit('leave_project', { projectId: pid });
}
s.disconnect();
if (globalSocket === s) globalSocket = null;
setSocket(null);
setConnected(false);
setCollabConnected(false);
};
}, [token, setCollabConnected, setConnected, setSocket, pushPending, shiftPending]);
}
function usePageVisibility() {
const visibleRef = useRef(!document.hidden);
useEffect(() => {
const handler = () => { visibleRef.current = !document.hidden; };
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}, []);
return visibleRef;
}
// ─── Project-scoped socket: join/leave room + emit functions ───
export function useProjectSocket(projectId: string | undefined) {
const setCurrentProjectId = useWsStore((s) => s.setCurrentProjectId);
const pageVisible = usePageVisibility();
// Join/leave project room when projectId changes
useEffect(() => {
if (!projectId) {
setCurrentProjectId(null);
return;
}
setCurrentProjectId(projectId);
const s = globalSocket;
if (s?.connected) {
s.emit('join_project', { projectId });
}
return () => {
setCurrentProjectId(null);
if (s?.connected) {
s.emit('leave_project', { projectId });
}
};
}, [projectId, setCurrentProjectId]);
// Leave/rejoin project room when tab visibility changes
useEffect(() => {
if (!projectId) return;
const handler = () => {
const s = globalSocket;
if (!s?.connected) return;
if (document.hidden) {
s.emit('leave_project', { projectId });
} else {
s.emit('join_project', { projectId });
}
};
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}, [projectId]);
// ─── Generic emit for project actions ───
const emitProjectAction = useCallback(<T>(action: string, data: any): Promise<T> => {
return new Promise((resolve, reject) => {
const s = globalSocket;
if (!s?.connected || !projectId) {
reject(new Error('Not connected'));
return;
}
s.emit(action, { projectId, ...data }, (response: any) => {
if (response?.error) reject(new Error(response.error));
else resolve(response as T);
});
});
}, [projectId]);
const emitFileChanged = useCallback((fileId: string, content: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('file_changed', { projectId, fileId, content });
}
}, [projectId]);
const emitActiveFileChanged = useCallback((fileId: string | null) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('active_file_changed', { projectId, fileId });
}
}, [projectId]);
const emitCursorMove = useCallback((fileId: string, cursor: { line: number; ch: number }) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('cursor_move', { projectId, fileId, cursor });
}
}, [projectId]);
const emitBlockSelection = useCallback((blockId: string | null) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('block_selection_changed', { projectId, blockId });
}
}, [projectId]);
const emitElementHover = useCallback((elementId: string | null) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('visual_element_hover', { projectId, elementId });
}
}, [projectId]);
const emitFileAdded = useCallback((file: any, parentId?: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('file_added', { projectId, file, parentId });
}
}, [projectId]);
const emitFileRenamed = useCallback((fileId: string, name: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('file_renamed', { projectId, fileId, name });
}
}, [projectId]);
const emitFileDeleted = useCallback((fileId: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('file_deleted', { projectId, fileId });
}
}, [projectId]);
const emitVisualElementAdded = useCallback((element: any, parentId?: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('visual_element_added', { projectId, element, parentId });
}
}, [projectId]);
const emitVisualElementUpdated = useCallback((elementId: string, updates: any) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('visual_element_updated', { projectId, elementId, updates });
}
}, [projectId]);
const emitVisualElementRemoved = useCallback((elementId: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('visual_element_removed', { projectId, elementId });
}
}, [projectId]);
const emitVisualElementsReordered = useCallback((elements: any[]) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('visual_elements_reordered', { projectId, elements });
}
}, [projectId]);
const emitSave = useCallback((data: any): Promise<any> => {
return new Promise((resolve, reject) => {
const s = globalSocket;
if (!s?.connected || !projectId) {
reject(new Error('Not connected'));
return;
}
s.emit('save_project', { projectId, data }, (response: any) => {
if (response?.error) reject(new Error(response.error));
else resolve(response);
});
});
}, [projectId]);
const lastPointerEmit = useRef(0);
const emitPointerMove = useCallback((x: number, y: number) => {
const now = Date.now();
if (now - lastPointerEmit.current < 100) return;
lastPointerEmit.current = now;
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('pointer_move', { projectId, x, y });
}
}, [projectId]);
const emitBlockDragStart = useCallback((blockId: string, x: number, y: number) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('block_drag_start', { projectId, blockId, x, y });
}
}, [projectId]);
const lastBlockDragEmit = useRef(0);
const emitBlockDragMove = useCallback((blockId: string, x: number, y: number) => {
const now = Date.now();
if (now - lastBlockDragEmit.current < 50) return;
lastBlockDragEmit.current = now;
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('block_drag_move', { projectId, blockId, x, y });
}
}, [projectId]);
const emitBlockDragEnd = useCallback((blockId: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('block_drag_end', { projectId, blockId });
}
}, [projectId]);
const emitBlockStateSync = useCallback((fileId: string, xml: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
console.log('[emitBlockStateSync] sending:', { projectId, fileId, xmlLength: xml?.length, socketConnected: s?.connected });
s.emit('block_state_sync', { projectId, fileId, xml });
} else {
console.warn('[emitBlockStateSync] FAILED: socket not connected or no projectId', { connected: s?.connected, projectId });
}
}, [projectId]);
const emitBlockConnect = useCallback((childId: string, parentId: string, inputName?: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('block_connect', { projectId, childId, parentId, inputName });
}
}, [projectId]);
const emitBlockChange = useCallback((blockId: string, name: string, value: any, element: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('block_change', { projectId, blockId, name, value, element });
}
}, [projectId]);
const emitBlockCreateEvent = useCallback((blockId: string, fileId: string, xml: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('block_create_event', { projectId, fileId, blockId, xml });
}
}, [projectId]);
const emitTextFieldFocus = useCallback((fieldId: string, fileId: string, cursorPosition: number) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('text_field_focus', { projectId, fieldId, fileId, cursorPosition });
}
}, [projectId]);
const lastTextFieldCursorEmit = useRef(0);
const emitTextFieldCursor = useCallback((fieldId: string, cursorPosition: number) => {
const now = Date.now();
if (now - lastTextFieldCursorEmit.current < 80) return;
lastTextFieldCursorEmit.current = now;
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('text_field_cursor', { projectId, fieldId, cursorPosition });
}
}, [projectId]);
const emitTextFieldBlur = useCallback((fieldId: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('text_field_blur', { projectId, fieldId });
}
}, [projectId]);
const emitCssRuleAdded = useCallback((rule: any) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('css_rule_added', { projectId, rule });
}
}, [projectId]);
const emitCssRuleUpdated = useCallback((ruleId: string, updates: any) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('css_rule_updated', { projectId, ruleId, updates });
}
}, [projectId]);
const emitCssRuleRemoved = useCallback((ruleId: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('css_rule_removed', { projectId, ruleId });
}
}, [projectId]);
const emitCssRuleReordered = useCallback((ruleIds: string[]) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('css_rule_reordered', { projectId, ruleIds });
}
}, [projectId]);
const emitCssPropAdded = useCallback((ruleId: string, property: any) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('css_prop_added', { projectId, ruleId, property });
}
}, [projectId]);
const emitCssPropUpdated = useCallback((ruleId: string, propertyId: string, updates: any) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('css_prop_updated', { projectId, ruleId, propertyId, updates });
}
}, [projectId]);
const emitCssPropRemoved = useCallback((ruleId: string, propertyId: string) => {
const s = globalSocket;
if (s?.connected && projectId) {
s.emit('css_prop_removed', { projectId, ruleId, propertyId });
}
}, [projectId]);
return {
emitProjectAction,
emitFileChanged,
emitActiveFileChanged,
emitCursorMove,
emitBlockSelection,
emitElementHover,
emitFileAdded,
emitFileRenamed,
emitFileDeleted,
emitVisualElementAdded,
emitVisualElementUpdated,
emitVisualElementRemoved,
emitVisualElementsReordered,
emitSave,
emitPointerMove,
emitBlockDragStart,
emitBlockDragMove,
emitBlockDragEnd,
emitBlockStateSync,
emitBlockConnect,
emitBlockChange,
emitBlockCreateEvent,
emitTextFieldFocus,
emitTextFieldCursor,
emitTextFieldBlur,
emitCssRuleAdded,
emitCssRuleUpdated,
emitCssRuleRemoved,
emitCssRuleReordered,
emitCssPropAdded,
emitCssPropUpdated,
emitCssPropRemoved,
};
}
export function getSocket(): Socket | null {
return globalSocket;
}