concurrency
This commit is contained in:
@@ -11,6 +11,7 @@ import clsx from 'clsx';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { useUpload } from '../context/UploadContext';
|
||||
import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared';
|
||||
import { isLatestRequest, mergeUniqueDrawings } from './dashboard/pagination';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
@@ -92,7 +93,7 @@ export const Dashboard: React.FC = () => {
|
||||
}),
|
||||
api.getCollections()
|
||||
]);
|
||||
if (requestVersion !== listRequestVersionRef.current) return;
|
||||
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
||||
setDrawings(drawingsRes.drawings);
|
||||
setTotalCount(drawingsRes.totalCount);
|
||||
setCollections(collectionsData);
|
||||
@@ -100,7 +101,7 @@ export const Dashboard: React.FC = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
} finally {
|
||||
if (requestVersion === listRequestVersionRef.current) {
|
||||
if (isLatestRequest(requestVersion, listRequestVersionRef.current)) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -117,12 +118,8 @@ export const Dashboard: React.FC = () => {
|
||||
sortField: sortConfig.field,
|
||||
sortDirection: sortConfig.direction,
|
||||
});
|
||||
if (requestVersion !== listRequestVersionRef.current) return;
|
||||
setDrawings(prev => {
|
||||
const seen = new Set(prev.map((d) => d.id));
|
||||
const nextPage = drawingsRes.drawings.filter((d) => !seen.has(d.id));
|
||||
return [...prev, ...nextPage];
|
||||
});
|
||||
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
||||
setDrawings(prev => mergeUniqueDrawings(prev, drawingsRes.drawings));
|
||||
setTotalCount(drawingsRes.totalCount);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch more data:', err);
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
getColorFromString,
|
||||
getFilesDelta,
|
||||
getInitialsFromName,
|
||||
hasRenderableElements,
|
||||
haveSameElements,
|
||||
isSuspiciousEmptySnapshot,
|
||||
} from './editor/shared';
|
||||
import type { ElementVersionInfo } from './editor/shared';
|
||||
|
||||
@@ -124,7 +126,9 @@ export const Editor: React.FC = () => {
|
||||
const latestFilesRef = useRef<any>(null);
|
||||
const lastSyncedFilesRef = useRef<Record<string, any>>({});
|
||||
const latestAppStateRef = useRef<any>(null);
|
||||
const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any) => void) | null>(null);
|
||||
const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => void) | null>(null);
|
||||
const currentDrawingVersionRef = useRef<number | null>(null);
|
||||
const lastPersistedElementsRef = useRef<readonly any[]>([]);
|
||||
|
||||
const emitFilesDeltaIfNeeded = useCallback(
|
||||
(nextFiles: Record<string, any>) => {
|
||||
@@ -362,7 +366,7 @@ export const Editor: React.FC = () => {
|
||||
|
||||
// Persist after file data becomes available so new tabs (tab3) load correctly.
|
||||
if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current);
|
||||
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -428,11 +432,11 @@ export const Editor: React.FC = () => {
|
||||
scrollToContent: true,
|
||||
}), []);
|
||||
|
||||
const saveDataRef = useRef<((drawingId: string, elements: readonly any[], appState: 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 saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
|
||||
|
||||
saveDataRef.current = async (drawingId: string, elements: readonly any[], appState: any) => {
|
||||
saveDataRef.current = async (drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => {
|
||||
if (!drawingId) return;
|
||||
|
||||
try {
|
||||
@@ -442,24 +446,38 @@ export const Editor: React.FC = () => {
|
||||
gridSize: appState?.gridSize || null,
|
||||
};
|
||||
|
||||
const snapshot = latestElementsRef.current ?? elements ?? [];
|
||||
const persistableElements = Array.isArray(snapshot) ? snapshot : [];
|
||||
const persistableElements = Array.isArray(elements) ? elements : [];
|
||||
if (isSuspiciousEmptySnapshot(lastPersistedElementsRef.current, persistableElements)) {
|
||||
console.warn("[Editor] Skipping suspicious empty snapshot save", { drawingId });
|
||||
return;
|
||||
}
|
||||
const persistableFiles = files ?? latestFilesRef.current ?? {};
|
||||
|
||||
console.log("[Editor] Saving drawing", {
|
||||
drawingId,
|
||||
elementCount: persistableElements.length,
|
||||
hasRenderableElements: persistableElements.some((el: any) => !el?.isDeleted),
|
||||
hasRenderableElements: hasRenderableElements(persistableElements),
|
||||
appState: persistableAppState,
|
||||
});
|
||||
|
||||
await api.updateDrawing(drawingId, {
|
||||
const updated = await api.updateDrawing(drawingId, {
|
||||
elements: persistableElements,
|
||||
appState: persistableAppState,
|
||||
files: latestFilesRef.current || {},
|
||||
files: persistableFiles,
|
||||
version: currentDrawingVersionRef.current ?? undefined,
|
||||
});
|
||||
if (typeof updated.version === "number") {
|
||||
currentDrawingVersionRef.current = updated.version;
|
||||
}
|
||||
lastPersistedElementsRef.current = persistableElements;
|
||||
|
||||
console.log("[Editor] Save complete", { drawingId });
|
||||
} catch (err) {
|
||||
if (api.isAxiosError(err) && err.response?.status === 409) {
|
||||
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
|
||||
toast.error("Drawing changed in another tab. Refresh to load latest.");
|
||||
return;
|
||||
}
|
||||
console.error('Failed to save drawing', err);
|
||||
toast.error("Failed to save changes");
|
||||
}
|
||||
@@ -509,9 +527,9 @@ export const Editor: React.FC = () => {
|
||||
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((drawingId, elements, appState) => {
|
||||
debounce((drawingId, elements, appState, files) => {
|
||||
if (saveDataRef.current) {
|
||||
saveDataRef.current(drawingId, elements, appState);
|
||||
saveDataRef.current(drawingId, elements, appState, files);
|
||||
}
|
||||
}, 1000),
|
||||
[] // Empty dependency array = Stable across renders
|
||||
@@ -587,6 +605,8 @@ export const Editor: React.FC = () => {
|
||||
latestElementsRef.current = [];
|
||||
latestFilesRef.current = {};
|
||||
lastSyncedFilesRef.current = {};
|
||||
currentDrawingVersionRef.current = null;
|
||||
lastPersistedElementsRef.current = [];
|
||||
excalidrawAPI.current = null;
|
||||
setIsReady(false);
|
||||
setIsSceneLoading(true);
|
||||
@@ -614,6 +634,8 @@ export const Editor: React.FC = () => {
|
||||
latestElementsRef.current = elements;
|
||||
latestFilesRef.current = files;
|
||||
lastSyncedFilesRef.current = files;
|
||||
currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null;
|
||||
lastPersistedElementsRef.current = elements;
|
||||
|
||||
elements.forEach((el: any) => {
|
||||
recordElementVersion(el);
|
||||
@@ -657,6 +679,8 @@ export const Editor: React.FC = () => {
|
||||
latestElementsRef.current = [];
|
||||
latestFilesRef.current = {};
|
||||
lastSyncedFilesRef.current = {};
|
||||
currentDrawingVersionRef.current = null;
|
||||
lastPersistedElementsRef.current = [];
|
||||
setLoadError(message);
|
||||
setInitialData(null);
|
||||
} finally {
|
||||
@@ -678,7 +702,7 @@ export const Editor: React.FC = () => {
|
||||
latestElementsRef.current = elements;
|
||||
latestFilesRef.current = files;
|
||||
if (!id) return;
|
||||
await saveDataRef.current(id, elements, appState);
|
||||
await saveDataRef.current(id, elements, appState, files);
|
||||
savePreviewRef.current(id, elements, appState, files);
|
||||
toast.success("Saved changes to server");
|
||||
}
|
||||
@@ -729,8 +753,8 @@ export const Editor: React.FC = () => {
|
||||
|
||||
latestElementsRef.current = allElements;
|
||||
|
||||
const hasRenderableElements = allElements.some((el: any) => !el?.isDeleted);
|
||||
if (isBootstrappingScene.current && !hasRenderableElements) {
|
||||
const hasRenderable = hasRenderableElements(allElements);
|
||||
if (isBootstrappingScene.current && !hasRenderable) {
|
||||
console.log("[Editor] Bootstrapping guard active", {
|
||||
drawingId: id,
|
||||
elementCount: allElements.length,
|
||||
@@ -741,19 +765,20 @@ export const Editor: React.FC = () => {
|
||||
// Trigger Sync (Throttled)
|
||||
broadcastChanges(allElements, currentFiles);
|
||||
|
||||
const filesSnapshot = currentFiles;
|
||||
latestFilesRef.current = filesSnapshot;
|
||||
|
||||
// Trigger Fast Save
|
||||
console.log("[Editor] Queueing save", {
|
||||
drawingId: id,
|
||||
elementCount: allElements.length,
|
||||
hasRenderableElements,
|
||||
hasRenderableElements: hasRenderable,
|
||||
});
|
||||
if (id) {
|
||||
debouncedSave(id, allElements, appState);
|
||||
debouncedSave(id, allElements, appState, filesSnapshot);
|
||||
}
|
||||
|
||||
// Trigger Slow Preview Gen
|
||||
const filesSnapshot = currentFiles;
|
||||
latestFilesRef.current = filesSnapshot;
|
||||
console.log("[Editor] Queueing preview save", {
|
||||
drawingId: id,
|
||||
fileCount: Object.keys(filesSnapshot).length,
|
||||
@@ -779,7 +804,7 @@ export const Editor: React.FC = () => {
|
||||
|
||||
// Persist after file data becomes available (covers the "tab 3" case).
|
||||
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current);
|
||||
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
@@ -823,7 +848,7 @@ export const Editor: React.FC = () => {
|
||||
latestFilesRef.current = files;
|
||||
|
||||
await Promise.all([
|
||||
saveDataRef.current(id, elements, appState),
|
||||
saveDataRef.current(id, elements, appState, files),
|
||||
savePreviewRef.current(id, elements, appState, files)
|
||||
]);
|
||||
console.log("[Editor] Saved on back navigation", { drawingId: id });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLatestRequest, mergeUniqueDrawings } from "./pagination";
|
||||
import type { DrawingSummary } from "../../types";
|
||||
|
||||
const drawing = (id: string, name = id): DrawingSummary => ({
|
||||
id,
|
||||
name,
|
||||
collectionId: null,
|
||||
preview: null,
|
||||
version: 1,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
describe("dashboard pagination helpers", () => {
|
||||
it("accepts only latest request version", () => {
|
||||
expect(isLatestRequest(3, 3)).toBe(true);
|
||||
expect(isLatestRequest(2, 3)).toBe(false);
|
||||
expect(isLatestRequest(4, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it("merges pages without duplicating IDs", () => {
|
||||
const existing = [drawing("a"), drawing("b")];
|
||||
const incoming = [drawing("b", "b-new"), drawing("c")];
|
||||
|
||||
const merged = mergeUniqueDrawings(existing, incoming);
|
||||
|
||||
expect(merged.map((d) => d.id)).toEqual(["a", "b", "c"]);
|
||||
expect(merged[1].name).toBe("b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { DrawingSummary } from "../../types";
|
||||
|
||||
export const isLatestRequest = (requestVersion: number, currentVersion: number): boolean =>
|
||||
requestVersion === currentVersion;
|
||||
|
||||
export const mergeUniqueDrawings = (
|
||||
existing: DrawingSummary[],
|
||||
incoming: DrawingSummary[]
|
||||
): DrawingSummary[] => {
|
||||
const seen = new Set(existing.map((d) => d.id));
|
||||
const nextPage = incoming.filter((d) => !seen.has(d.id));
|
||||
return [...existing, ...nextPage];
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasRenderableElements,
|
||||
isSuspiciousEmptySnapshot,
|
||||
} from "./shared";
|
||||
|
||||
describe("editor/shared scene guards", () => {
|
||||
it("detects renderable elements", () => {
|
||||
expect(hasRenderableElements([{ id: "a", isDeleted: false }])).toBe(true);
|
||||
expect(
|
||||
hasRenderableElements([
|
||||
{ id: "a", isDeleted: true },
|
||||
{ id: "b", isDeleted: true },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("flags empty snapshot after a previously non-empty persisted scene", () => {
|
||||
const previous = [{ id: "a", isDeleted: false }];
|
||||
expect(isSuspiciousEmptySnapshot(previous, [])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag empty snapshot for already-empty drawings", () => {
|
||||
expect(isSuspiciousEmptySnapshot([], [])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag non-empty snapshots", () => {
|
||||
const previous = [{ id: "a", isDeleted: false }];
|
||||
const next = [{ id: "a", isDeleted: true }];
|
||||
expect(isSuspiciousEmptySnapshot(previous, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,21 @@ export const haveSameElements = (a: readonly any[] = [], b: readonly any[] = [])
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasRenderableElements = (elements: readonly any[] = []): boolean =>
|
||||
elements.some((element: any) => !element?.isDeleted);
|
||||
|
||||
/**
|
||||
* Guard against transient empty snapshots (e.g. hydration/reload races) from
|
||||
* overwriting a previously persisted non-empty drawing.
|
||||
*/
|
||||
export const isSuspiciousEmptySnapshot = (
|
||||
previousPersisted: readonly any[] = [],
|
||||
nextSnapshot: readonly any[] = []
|
||||
): boolean => {
|
||||
if (!Array.isArray(nextSnapshot) || nextSnapshot.length > 0) return false;
|
||||
return hasRenderableElements(previousPersisted);
|
||||
};
|
||||
|
||||
const buildFileSignature = (file: any): string => {
|
||||
const mimeType = typeof file?.mimeType === "string" ? file.mimeType : "";
|
||||
const id = typeof file?.id === "string" ? file.id : "";
|
||||
|
||||
Reference in New Issue
Block a user