fix(editor): flush lifecycle saves and recover from version conflicts
This commit is contained in:
+165
-10
@@ -77,6 +77,7 @@ export const Editor: React.FC = () => {
|
|||||||
const lastCursorEmit = useRef<number>(0);
|
const lastCursorEmit = useRef<number>(0);
|
||||||
const lastPointerButtonRef = useRef<string>("up");
|
const lastPointerButtonRef = useRef<string>("up");
|
||||||
const lastPointerSelectionSigRef = useRef<string>("{}");
|
const lastPointerSelectionSigRef = useRef<string>("{}");
|
||||||
|
const lastPointerRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map());
|
const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map());
|
||||||
const isBootstrappingScene = useRef(true);
|
const isBootstrappingScene = useRef(true);
|
||||||
const hasHydratedInitialScene = useRef(false);
|
const hasHydratedInitialScene = useRef(false);
|
||||||
@@ -98,6 +99,7 @@ export const Editor: React.FC = () => {
|
|||||||
const hasSceneChangesSinceLoadRef = useRef(false);
|
const hasSceneChangesSinceLoadRef = useRef(false);
|
||||||
const touchedElementIdsRef = useRef<Set<string>>(new Set());
|
const touchedElementIdsRef = useRef<Set<string>>(new Set());
|
||||||
const pointerDownRef = useRef(false);
|
const pointerDownRef = useRef(false);
|
||||||
|
const finalSyncTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const getRenderableBaselineSnapshot = useCallback((): readonly any[] => {
|
const getRenderableBaselineSnapshot = useCallback((): readonly any[] => {
|
||||||
if (hasRenderableElements(lastPersistedElementsRef.current)) {
|
if (hasRenderableElements(lastPersistedElementsRef.current)) {
|
||||||
@@ -315,10 +317,45 @@ export const Editor: React.FC = () => {
|
|||||||
touchedElementIdsRef.current.clear();
|
touchedElementIdsRef.current.clear();
|
||||||
}, [canEditScene, id, me.id, recordElementVersion]);
|
}, [canEditScene, id, me.id, recordElementVersion]);
|
||||||
|
|
||||||
|
const emitCursorPresence = useCallback((button: string = "up") => {
|
||||||
|
if (!socketRef.current || !id) return;
|
||||||
|
const selectedElementIds = excalidrawAPI.current?.getAppState?.().selectedElementIds || {};
|
||||||
|
const selectionSig = JSON.stringify(Object.keys(selectedElementIds || {}).sort());
|
||||||
|
|
||||||
|
socketRef.current.emit("cursor-move", {
|
||||||
|
pointer: lastPointerRef.current || { x: 0, y: 0 },
|
||||||
|
button,
|
||||||
|
selectedElementIds,
|
||||||
|
username: me.name,
|
||||||
|
userId: me.id,
|
||||||
|
drawingId: id,
|
||||||
|
color: me.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastPointerButtonRef.current = button;
|
||||||
|
lastPointerSelectionSigRef.current = selectionSig;
|
||||||
|
lastCursorEmit.current = Date.now();
|
||||||
|
}, [id, me]);
|
||||||
|
|
||||||
|
const queueFinalPointerSync = useCallback(() => {
|
||||||
|
emitFinalPointerSync();
|
||||||
|
if (finalSyncTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(finalSyncTimeoutRef.current);
|
||||||
|
}
|
||||||
|
finalSyncTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
emitFinalPointerSync();
|
||||||
|
finalSyncTimeoutRef.current = null;
|
||||||
|
}, 60);
|
||||||
|
}, [emitFinalPointerSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isUnmounting.current = false;
|
isUnmounting.current = false;
|
||||||
return () => {
|
return () => {
|
||||||
isUnmounting.current = true;
|
isUnmounting.current = true;
|
||||||
|
if (finalSyncTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(finalSyncTimeoutRef.current);
|
||||||
|
finalSyncTimeoutRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -482,13 +519,15 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
const onPointerUpdate = useCallback((payload: any) => {
|
const onPointerUpdate = useCallback((payload: any) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
if (payload?.pointer && typeof payload.pointer.x === "number" && typeof payload.pointer.y === "number") {
|
||||||
|
lastPointerRef.current = payload.pointer;
|
||||||
|
}
|
||||||
const button = typeof payload?.button === "string" ? payload.button : "up";
|
const button = typeof payload?.button === "string" ? payload.button : "up";
|
||||||
const selectedElementIdsFromPayload =
|
const selectedElementIdsFromPayload =
|
||||||
(payload?.selectedElementIds && typeof payload.selectedElementIds === "object")
|
(payload?.selectedElementIds && typeof payload.selectedElementIds === "object")
|
||||||
? payload.selectedElementIds
|
? payload.selectedElementIds
|
||||||
: (excalidrawAPI.current?.getAppState?.().selectedElementIds || {});
|
: (excalidrawAPI.current?.getAppState?.().selectedElementIds || {});
|
||||||
const selectedElementIds =
|
const selectedElementIds = selectedElementIdsFromPayload;
|
||||||
button !== "up" ? selectedElementIdsFromPayload : {};
|
|
||||||
const selectionSig = JSON.stringify(
|
const selectionSig = JSON.stringify(
|
||||||
Object.keys(selectedElementIds || {}).sort()
|
Object.keys(selectedElementIds || {}).sort()
|
||||||
);
|
);
|
||||||
@@ -515,9 +554,12 @@ export const Editor: React.FC = () => {
|
|||||||
pointerDownRef.current = true;
|
pointerDownRef.current = true;
|
||||||
} else if (pointerDownRef.current) {
|
} else if (pointerDownRef.current) {
|
||||||
pointerDownRef.current = false;
|
pointerDownRef.current = false;
|
||||||
emitFinalPointerSync();
|
queueFinalPointerSync();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
emitCursorPresence("up");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [id, me, emitFinalPointerSync]);
|
}, [id, me, queueFinalPointerSync, emitCursorPresence]);
|
||||||
|
|
||||||
// Refs for API interaction
|
// Refs for API interaction
|
||||||
const excalidrawAPI = useRef<any>(null);
|
const excalidrawAPI = useRef<any>(null);
|
||||||
@@ -616,6 +658,57 @@ export const Editor: React.FC = () => {
|
|||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
const recoverFromVersionConflict = useCallback(
|
||||||
|
async (
|
||||||
|
drawingId: string,
|
||||||
|
localElements: readonly any[],
|
||||||
|
localFiles: Record<string, any>
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const latest = await api.getDrawing(drawingId);
|
||||||
|
const latestElements = Array.isArray(latest.elements) ? latest.elements : [];
|
||||||
|
const latestFiles = latest.files || {};
|
||||||
|
const mergedFiles = { ...latestFiles, ...(localFiles || {}) };
|
||||||
|
const normalizedLocalElements = normalizeImageElementStatus(localElements, mergedFiles);
|
||||||
|
const mergedElements = reconcileElements(latestElements, normalizedLocalElements);
|
||||||
|
|
||||||
|
if (typeof latest.version === "number") {
|
||||||
|
currentDrawingVersionRef.current = latest.version;
|
||||||
|
}
|
||||||
|
latestElementsRef.current = mergedElements;
|
||||||
|
initialSceneElementsRef.current = latestElements;
|
||||||
|
lastPersistedElementsRef.current = latestElements;
|
||||||
|
latestFilesRef.current = mergedFiles;
|
||||||
|
lastSyncedFilesRef.current = mergedFiles;
|
||||||
|
hasSceneChangesSinceLoadRef.current = false;
|
||||||
|
|
||||||
|
elementVersionMap.current.clear();
|
||||||
|
mergedElements.forEach((element: any) => {
|
||||||
|
recordElementVersion(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (excalidrawAPI.current) {
|
||||||
|
isSyncing.current = true;
|
||||||
|
if (
|
||||||
|
Object.keys(mergedFiles).length > 0 &&
|
||||||
|
typeof excalidrawAPI.current.addFiles === "function"
|
||||||
|
) {
|
||||||
|
excalidrawAPI.current.addFiles(Object.values(mergedFiles));
|
||||||
|
}
|
||||||
|
excalidrawAPI.current.updateScene({ elements: mergedElements });
|
||||||
|
isSyncing.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.info("Loaded latest collaborator changes");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Editor] Failed to recover from version conflict", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[normalizeImageElementStatus, recordElementVersion]
|
||||||
|
);
|
||||||
|
|
||||||
const saveDataRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => Promise<void>) | null>(null);
|
const saveDataRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => Promise<void>) | null>(null);
|
||||||
const savePreviewRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
|
const savePreviewRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
|
||||||
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
|
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
|
||||||
@@ -670,7 +763,7 @@ export const Editor: React.FC = () => {
|
|||||||
appState: persistableAppState,
|
appState: persistableAppState,
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistScene = async (attempt: number): Promise<void> => {
|
const persistScene = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateDrawing(drawingId, {
|
const updated = await api.updateDrawing(drawingId, {
|
||||||
elements: normalizedElementsForSave,
|
elements: normalizedElementsForSave,
|
||||||
@@ -691,15 +784,27 @@ export const Editor: React.FC = () => {
|
|||||||
currentDrawingVersionRef.current = reportedVersion;
|
currentDrawingVersionRef.current = reportedVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt === 0 && hasReportedVersion) {
|
console.warn("[Editor] Version conflict while saving drawing, recovering latest", {
|
||||||
console.warn("[Editor] Version conflict while saving drawing, retrying once", {
|
|
||||||
drawingId,
|
drawingId,
|
||||||
currentVersion: reportedVersion,
|
currentVersion: reportedVersion,
|
||||||
});
|
});
|
||||||
await persistScene(1);
|
|
||||||
|
const recovered = await recoverFromVersionConflict(
|
||||||
|
drawingId,
|
||||||
|
normalizedElementsForSave,
|
||||||
|
persistableFiles
|
||||||
|
);
|
||||||
|
if (recovered) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasReportedVersion) {
|
||||||
|
console.warn("[Editor] Version conflict recovery failed", {
|
||||||
|
drawingId,
|
||||||
|
currentVersion: reportedVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw new DrawingSaveConflictError();
|
throw new DrawingSaveConflictError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,11 +812,11 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await persistScene(0);
|
await persistScene();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DrawingSaveConflictError) {
|
if (err instanceof DrawingSaveConflictError) {
|
||||||
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
|
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
|
||||||
toast.error("Drawing changed in another tab. Refresh to load latest.");
|
toast.error("Drawing changed in another tab and recovery failed. Refresh to load latest.");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
console.error('Failed to save drawing', err);
|
console.error('Failed to save drawing', err);
|
||||||
@@ -852,6 +957,51 @@ export const Editor: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [debouncedSave, debouncedSavePreview]);
|
}, [debouncedSave, debouncedSavePreview]);
|
||||||
|
|
||||||
|
const flushPendingSavesForLifecycle = useCallback(() => {
|
||||||
|
if (!canEditScene || !id) return;
|
||||||
|
|
||||||
|
emitFinalPointerSync();
|
||||||
|
debouncedSave.flush();
|
||||||
|
debouncedSavePreview.flush();
|
||||||
|
|
||||||
|
const snapshotElements = excalidrawAPI.current?.getSceneElementsIncludingDeleted?.() ?? latestElementsRef.current;
|
||||||
|
const snapshotAppState = excalidrawAPI.current?.getAppState?.() ?? latestAppStateRef.current;
|
||||||
|
const snapshotFiles = excalidrawAPI.current?.getFiles?.() ?? latestFilesRef.current ?? {};
|
||||||
|
|
||||||
|
if (!snapshotAppState || !saveDataRef.current) return;
|
||||||
|
|
||||||
|
hasSceneChangesSinceLoadRef.current = true;
|
||||||
|
void enqueueSceneSave(id, snapshotElements, snapshotAppState, snapshotFiles);
|
||||||
|
}, [canEditScene, id, debouncedSave, debouncedSavePreview, emitFinalPointerSync, enqueueSceneSave]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !canEditScene) return;
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === "hidden") {
|
||||||
|
flushPendingSavesForLifecycle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageHide = () => {
|
||||||
|
flushPendingSavesForLifecycle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
flushPendingSavesForLifecycle();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.addEventListener("pagehide", handlePageHide);
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.removeEventListener("pagehide", handlePageHide);
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [id, canEditScene, flushPendingSavesForLifecycle]);
|
||||||
|
|
||||||
const broadcastChanges = useCallback(
|
const broadcastChanges = useCallback(
|
||||||
throttle((elements: readonly any[], currentFiles?: Record<string, any>) => {
|
throttle((elements: readonly any[], currentFiles?: Record<string, any>) => {
|
||||||
if (!canEditScene) return;
|
if (!canEditScene) return;
|
||||||
@@ -909,8 +1059,13 @@ export const Editor: React.FC = () => {
|
|||||||
hasSceneChangesSinceLoadRef.current = false;
|
hasSceneChangesSinceLoadRef.current = false;
|
||||||
touchedElementIdsRef.current.clear();
|
touchedElementIdsRef.current.clear();
|
||||||
pointerDownRef.current = false;
|
pointerDownRef.current = false;
|
||||||
|
if (finalSyncTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(finalSyncTimeoutRef.current);
|
||||||
|
finalSyncTimeoutRef.current = null;
|
||||||
|
}
|
||||||
lastPointerButtonRef.current = "up";
|
lastPointerButtonRef.current = "up";
|
||||||
lastPointerSelectionSigRef.current = "{}";
|
lastPointerSelectionSigRef.current = "{}";
|
||||||
|
lastPointerRef.current = null;
|
||||||
excalidrawAPI.current = null;
|
excalidrawAPI.current = null;
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setIsSceneLoading(true);
|
setIsSceneLoading(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user