Fix save issue

This commit is contained in:
Zimeng Xiong
2025-11-21 22:45:14 -08:00
parent 9ee9d6ccfe
commit 6a57e668dd
5 changed files with 202 additions and 26 deletions
+1
View File
@@ -1,2 +1,3 @@
frontend/node_modules frontend/node_modules
.DS_Store .DS_Store
backend/prisma/*.db
Binary file not shown.
+35
View File
@@ -148,12 +148,26 @@ app.get("/drawings", async (req, res) => {
app.get("/drawings/:id", async (req, res) => { app.get("/drawings/:id", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
console.log("[API] Fetching drawing", { id });
const drawing = await prisma.drawing.findUnique({ where: { id } }); const drawing = await prisma.drawing.findUnique({ where: { id } });
if (!drawing) { if (!drawing) {
console.warn("[API] Drawing not found", { id });
return res.status(404).json({ error: "Drawing not found" }); return res.status(404).json({ error: "Drawing not found" });
} }
console.log("[API] Returning drawing", {
id,
elementCount: (() => {
try {
const parsed = JSON.parse(drawing.elements);
return Array.isArray(parsed) ? parsed.length : null;
} catch (_err) {
return null;
}
})(),
});
res.json({ res.json({
...drawing, ...drawing,
elements: JSON.parse(drawing.elements), elements: JSON.parse(drawing.elements),
@@ -195,6 +209,15 @@ app.put("/drawings/:id", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { name, elements, appState, collectionId, preview } = req.body; const { name, elements, appState, collectionId, preview } = req.body;
console.log("[API] Updating drawing", {
id,
hasElements: elements !== undefined,
elementCount:
elements && Array.isArray(elements) ? elements.length : undefined,
hasAppState: appState !== undefined,
hasPreview: preview !== undefined,
});
const data: any = { const data: any = {
version: { increment: 1 }, version: { increment: 1 },
}; };
@@ -210,6 +233,18 @@ app.put("/drawings/:id", async (req, res) => {
data, data,
}); });
console.log("[API] Update complete", {
id,
storedElementCount: (() => {
try {
const parsed = JSON.parse(updatedDrawing.elements);
return Array.isArray(parsed) ? parsed.length : null;
} catch (_err) {
return null;
}
})(),
});
res.json({ res.json({
...updatedDrawing, ...updatedDrawing,
elements: JSON.parse(updatedDrawing.elements), elements: JSON.parse(updatedDrawing.elements),
+156 -16
View File
@@ -22,6 +22,20 @@ interface ElementVersionInfo {
versionNonce: number; versionNonce: number;
} }
const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => {
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.id !== right.id) return false;
if ((left.version ?? 0) !== (right.version ?? 0)) return false;
if ((left.versionNonce ?? 0) !== (right.versionNonce ?? 0)) return false;
}
return true;
};
// Move UIOptions outside to prevent re-creation on every render // Move UIOptions outside to prevent re-creation on every render
const UIOptions = { const UIOptions = {
canvasActions: { canvasActions: {
@@ -41,6 +55,7 @@ export const Editor: React.FC = () => {
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [initialData, setInitialData] = useState<any>(null); const [initialData, setInitialData] = useState<any>(null);
const [isSceneLoading, setIsSceneLoading] = useState(true);
const [peers, setPeers] = useState<Peer[]>([]); const [peers, setPeers] = useState<Peer[]>([]);
const [me] = useState(getUserIdentity()); const [me] = useState(getUserIdentity());
@@ -48,9 +63,14 @@ export const Editor: React.FC = () => {
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const lastCursorEmit = useRef<number>(0); const lastCursorEmit = useRef<number>(0);
const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map()); const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map());
const isBootstrappingScene = useRef(true);
const hasHydratedInitialScene = useRef(false);
const isUnmounting = useRef(false);
const isSyncing = useRef(false); const isSyncing = useRef(false);
const cursorBuffer = useRef<Map<string, any>>(new Map()); const cursorBuffer = useRef<Map<string, any>>(new Map());
const animationFrameId = useRef<number>(0); const animationFrameId = useRef<number>(0);
const latestElementsRef = useRef<readonly any[]>([]);
const latestFilesRef = useRef<any>(null);
const recordElementVersion = useCallback((element: any) => { const recordElementVersion = useCallback((element: any) => {
elementVersionMap.current.set(element.id, { elementVersionMap.current.set(element.id, {
@@ -69,6 +89,13 @@ export const Editor: React.FC = () => {
return previous.version !== nextVersion || previous.versionNonce !== nextNonce; return previous.version !== nextVersion || previous.versionNonce !== nextNonce;
}, []); }, []);
useEffect(() => {
isUnmounting.current = false;
return () => {
isUnmounting.current = true;
};
}, []);
useEffect(() => { useEffect(() => {
if (!id || !isReady) return; if (!id || !isReady) return;
@@ -146,6 +173,7 @@ export const Editor: React.FC = () => {
}); });
excalidrawAPI.current.updateScene({ elements: mergedElements }); excalidrawAPI.current.updateScene({ elements: mergedElements });
latestElementsRef.current = mergedElements;
isSyncing.current = false; isSyncing.current = false;
}); });
@@ -200,16 +228,26 @@ export const Editor: React.FC = () => {
setIsReady(true); setIsReady(true);
}, []); }, []);
const buildEmptyScene = useCallback(() => ({
elements: [],
appState: {
viewBackgroundColor: '#ffffff',
gridSize: null,
collaborators: new Map(),
},
scrollToContent: true,
}), []);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 1. STABLE SAVE LOGIC (The Fix) // 1. STABLE SAVE LOGIC (The Fix)
// We use a Ref to hold the save function so the debounce wrapper // We use a Ref to hold the save function so the debounce wrapper
// doesn't need to be recreated on every render. // doesn't need to be recreated on every render.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const saveDataRef = useRef<(elements: any, appState: any) => Promise<void>>(null); const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise<void>) | null>(null);
const savePreviewRef = useRef<(elements: any, appState: any, files: any) => Promise<void>>(null); const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
// Update the ref on every render to ensure it has access to the latest props/state // Update the ref on every render to ensure it has access to the latest props/state
saveDataRef.current = async (elements, appState) => { saveDataRef.current = async (elements: readonly any[], appState: any) => {
if (!id) return; if (!id) return;
try { try {
@@ -218,33 +256,55 @@ export const Editor: React.FC = () => {
gridSize: appState.gridSize, gridSize: appState.gridSize,
}; };
await api.updateDrawing(id, { const snapshot = latestElementsRef.current ?? elements;
elements, const persistableElements = Array.from(snapshot);
console.log("[Editor] Saving drawing", {
drawingId: id,
elementCount: persistableElements.length,
hasRenderableElements: persistableElements.some((el: any) => !el?.isDeleted),
appState: persistableAppState, appState: persistableAppState,
}); });
await api.updateDrawing(id, {
elements: persistableElements,
appState: persistableAppState,
});
console.log("[Editor] Save complete", { drawingId: id });
} catch (err) { } catch (err) {
console.error('Failed to save drawing', err); console.error('Failed to save drawing', err);
toast.error("Failed to save changes"); toast.error("Failed to save changes");
} }
}; };
savePreviewRef.current = async (elements, appState, files) => { savePreviewRef.current = async (elements: readonly any[], appState: any, files: any) => {
if (!id) return; if (!id) return;
try { try {
const currentSnapshot = latestElementsRef.current ?? elements;
const currentFiles = latestFilesRef.current ?? files;
// Generate preview // Generate preview
const svg = await exportToSvg({ const svg = await exportToSvg({
elements, elements: currentSnapshot,
appState: { appState: {
...appState, ...appState,
exportBackground: true, exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor || '#ffffff', viewBackgroundColor: appState.viewBackgroundColor || '#ffffff',
}, },
files, files: currentFiles,
}); });
const preview = svg.outerHTML; const preview = svg.outerHTML;
console.log("[Editor] Saving preview", {
drawingId: id,
elementCount: currentSnapshot.length,
});
await api.updateDrawing(id, { preview }); await api.updateDrawing(id, { preview });
console.log("[Editor] Preview save complete", { drawingId: id });
} catch (err) { } catch (err) {
console.error('Failed to save preview', err); console.error('Failed to save preview', err);
} }
@@ -298,33 +358,57 @@ export const Editor: React.FC = () => {
// 2. DATA LOADING // 2. DATA LOADING
// ------------------------------------------------------------------ // ------------------------------------------------------------------
useEffect(() => { useEffect(() => {
isBootstrappingScene.current = true;
hasHydratedInitialScene.current = false;
elementVersionMap.current.clear();
latestElementsRef.current = [];
latestFilesRef.current = null;
excalidrawAPI.current = null;
setIsReady(false);
setIsSceneLoading(true);
setInitialData(null);
const loadData = async () => { const loadData = async () => {
if (!id) return; if (!id) {
setInitialData(buildEmptyScene());
setIsSceneLoading(false);
return;
}
try { try {
const data = await api.getDrawing(id); const data = await api.getDrawing(id);
setDrawingName(data.name); setDrawingName(data.name);
const elements = convertToExcalidrawElements(data.elements || []); const elements = convertToExcalidrawElements(data.elements || []);
latestElementsRef.current = elements;
latestFilesRef.current = null;
// Initialize version tracking with loaded data
elements.forEach((el: any) => { elements.forEach((el: any) => {
recordElementVersion(el); recordElementVersion(el);
}); });
const persistedAppState = data.appState || {};
const hydratedAppState = {
...persistedAppState,
viewBackgroundColor: persistedAppState.viewBackgroundColor ?? '#ffffff',
gridSize: persistedAppState.gridSize ?? null,
collaborators: new Map(),
};
setInitialData({ setInitialData({
elements, elements,
appState: { appState: hydratedAppState,
...data.appState,
collaborators: new Map(),
},
scrollToContent: true, scrollToContent: true,
}); });
} catch (err) { } catch (err) {
console.error('Failed to load drawing', err); console.error('Failed to load drawing', err);
toast.error("Failed to load drawing");
setInitialData(buildEmptyScene());
} finally {
setIsSceneLoading(false);
} }
}; };
loadData(); loadData();
}, [id, recordElementVersion]); }, [id, recordElementVersion, buildEmptyScene]);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 3. HANDLERS // 3. HANDLERS
@@ -336,9 +420,11 @@ export const Editor: React.FC = () => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') { if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault(); e.preventDefault();
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
const elements = excalidrawAPI.current.getSceneElements(); const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const appState = excalidrawAPI.current.getAppState(); const appState = excalidrawAPI.current.getAppState();
const files = excalidrawAPI.current.getFiles() || null; const files = excalidrawAPI.current.getFiles() || null;
latestElementsRef.current = elements;
latestFilesRef.current = files;
// Call save immediately, bypassing debounce // Call save immediately, bypassing debounce
await saveDataRef.current(elements, appState); await saveDataRef.current(elements, appState);
// Also update preview // Also update preview
@@ -352,6 +438,11 @@ export const Editor: React.FC = () => {
}, []); }, []);
const handleCanvasChange = useCallback((elements: readonly any[], appState: any) => { const handleCanvasChange = useCallback((elements: readonly any[], appState: any) => {
if (isUnmounting.current) {
console.log("[Editor] Ignoring change during unmount", { drawingId: id });
return;
}
// 4. STOP THE ECHO // 4. STOP THE ECHO
// If this change was caused by a socket update, do NOT broadcast it back // If this change was caused by a socket update, do NOT broadcast it back
if (isSyncing.current) return; if (isSyncing.current) return;
@@ -361,14 +452,54 @@ export const Editor: React.FC = () => {
? excalidrawAPI.current.getSceneElementsIncludingDeleted() ? excalidrawAPI.current.getSceneElementsIncludingDeleted()
: elements; : elements;
if (!hasHydratedInitialScene.current) {
const matchesInitialSnapshot = haveSameElements(allElements, latestElementsRef.current);
hasHydratedInitialScene.current = true;
isBootstrappingScene.current = false;
if (matchesInitialSnapshot) {
console.log("[Editor] Skipping hydration change", {
drawingId: id,
elementCount: allElements.length,
});
return;
}
console.log("[Editor] First live change after hydration", {
drawingId: id,
elementCount: allElements.length,
});
}
latestElementsRef.current = allElements;
const hasRenderableElements = allElements.some((el: any) => !el?.isDeleted);
if (isBootstrappingScene.current && !hasRenderableElements) {
console.log("[Editor] Bootstrapping guard active", {
drawingId: id,
elementCount: allElements.length,
});
return;
}
// Trigger Sync (Throttled) // Trigger Sync (Throttled)
broadcastChanges(allElements); broadcastChanges(allElements);
// Trigger Fast Save // Trigger Fast Save
console.log("[Editor] Queueing save", {
drawingId: id,
elementCount: allElements.length,
hasRenderableElements,
});
debouncedSave(allElements, appState); debouncedSave(allElements, appState);
// Trigger Slow Preview Gen // Trigger Slow Preview Gen
const files = excalidrawAPI.current?.getFiles() || null; const files = excalidrawAPI.current?.getFiles() || null;
latestFilesRef.current = files;
console.log("[Editor] Queueing preview save", {
drawingId: id,
fileCount: files ? Object.keys(files).length : 0,
});
debouncedSavePreview(allElements, appState, files); debouncedSavePreview(allElements, appState, files);
}, [debouncedSave, debouncedSavePreview, broadcastChanges]); }, [debouncedSave, debouncedSavePreview, broadcastChanges]);
@@ -456,7 +587,9 @@ export const Editor: React.FC = () => {
</header> </header>
<div className="flex-1 w-full relative" style={{ height: 'calc(100vh - 3.5rem)' }}> <div className="flex-1 w-full relative" style={{ height: 'calc(100vh - 3.5rem)' }}>
{initialData ? (
<Excalidraw <Excalidraw
key={id}
theme={theme === 'dark' ? 'dark' : 'light'} theme={theme === 'dark' ? 'dark' : 'light'}
initialData={initialData} initialData={initialData}
onChange={handleCanvasChange} onChange={handleCanvasChange}
@@ -464,6 +597,13 @@ export const Editor: React.FC = () => {
excalidrawAPI={setExcalidrawAPI} excalidrawAPI={setExcalidrawAPI}
UIOptions={UIOptions} UIOptions={UIOptions}
/> />
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-gray-500 dark:text-gray-400">
<span className="text-sm font-medium">
{isSceneLoading ? 'Loading drawing...' : 'Preparing canvas...'}
</span>
</div>
)}
<Toaster position="bottom-center" /> <Toaster position="bottom-center" />
</div> </div>
</div> </div>
+1 -1
View File
@@ -15,7 +15,7 @@ export const reconcileElements = (
const getVersionNonce = (element: any) => element?.versionNonce ?? 0; const getVersionNonce = (element: any) => element?.versionNonce ?? 0;
const getUpdated = (element: any) => { const getUpdated = (element: any) => {
const value = element?.updated; const value = element?.updated;
return typeof value === 'number' ? value : Number(value) || 0; return typeof value === "number" ? value : Number(value) || 0;
}; };
remoteElements.forEach((remoteEl) => { remoteElements.forEach((remoteEl) => {