fix(drawings): stabilize lazy loading, improve export error handling, and tidy cache invalidation

This commit is contained in:
Zimeng Xiong
2025-12-01 13:58:24 -08:00
parent c4352185d6
commit 2520d7e7a2
2 changed files with 34 additions and 8 deletions
+15 -4
View File
@@ -112,12 +112,18 @@ const io = new Server(httpServer, {
maxHttpBufferSize: 1e8, // 100 MB maxHttpBufferSize: 1e8, // 100 MB
}); });
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const parseJsonField = <T>(rawValue: string | null | undefined, fallback: T): T => { const parseJsonField = <T>(
rawValue: string | null | undefined,
fallback: T
): T => {
if (!rawValue) return fallback; if (!rawValue) return fallback;
try { try {
return JSON.parse(rawValue) as T; return JSON.parse(rawValue) as T;
} catch (error) { } catch (error) {
console.warn("Failed to parse JSON field", { error, valuePreview: rawValue.slice(0, 50) }); console.warn("Failed to parse JSON field", {
error,
valuePreview: rawValue.slice(0, 50),
});
return fallback; return fallback;
} }
}; };
@@ -132,6 +138,11 @@ const DRAWINGS_CACHE_TTL_MS = (() => {
type DrawingsCacheEntry = { body: Buffer; expiresAt: number }; type DrawingsCacheEntry = { body: Buffer; expiresAt: number };
const drawingsCache = new Map<string, DrawingsCacheEntry>(); const drawingsCache = new Map<string, DrawingsCacheEntry>();
/**
* Builds a cache key for the drawings list endpoint.
* NOTE: This key does NOT include sort order. If sorting options are added
* to the endpoint in the future, they must be included in this key.
*/
const buildDrawingsCacheKey = (keyParts: { const buildDrawingsCacheKey = (keyParts: {
searchTerm: string; searchTerm: string;
collectionFilter: string; collectionFilter: string;
@@ -798,7 +809,7 @@ app.put("/drawings/:id", async (req, res) => {
where: { id }, where: { id },
data, data,
}); });
await invalidateDrawingsCache(); invalidateDrawingsCache();
console.log("[API] Update complete", { console.log("[API] Update complete", {
id, id,
@@ -927,7 +938,7 @@ app.delete("/collections/:id", async (req, res) => {
where: { id }, where: { id },
}), }),
]); ]);
await invalidateDrawingsCache(); invalidateDrawingsCache();
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
+19 -4
View File
@@ -58,6 +58,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
const [previewSvg, setPreviewSvg] = useState<string | null>(drawing.preview ?? null); const [previewSvg, setPreviewSvg] = useState<string | null>(drawing.preview ?? null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [fullData, setFullData] = useState<HydratedDrawingData | null>(null); const [fullData, setFullData] = useState<HydratedDrawingData | null>(null);
const fullDataRef = React.useRef(fullData); const fullDataRef = React.useRef(fullData);
@@ -69,14 +70,18 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
fullDataPromiseRef.current = null; fullDataPromiseRef.current = null;
}, [drawing.id]); }, [drawing.id]);
const ensureFullData = useCallback(async () => { const drawingIdRef = React.useRef(drawing.id);
drawingIdRef.current = drawing.id;
const ensureFullData = useCallback(async (): Promise<HydratedDrawingData> => {
if (fullDataRef.current) { if (fullDataRef.current) {
return fullDataRef.current; return fullDataRef.current;
} }
if (fullDataPromiseRef.current) { if (fullDataPromiseRef.current) {
return fullDataPromiseRef.current; return fullDataPromiseRef.current;
} }
const promise = api.getDrawing(drawing.id).then((fullDrawing) => { const currentDrawingId = drawingIdRef.current;
const promise = api.getDrawing(currentDrawingId).then((fullDrawing) => {
const payload: HydratedDrawingData = { const payload: HydratedDrawingData = {
elements: fullDrawing.elements || [], elements: fullDrawing.elements || [],
appState: fullDrawing.appState || {}, appState: fullDrawing.appState || {},
@@ -91,7 +96,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
}); });
fullDataPromiseRef.current = promise; fullDataPromiseRef.current = promise;
return promise; return promise;
}, [drawing.id]); }, []); // Stable identity - uses refs internally
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -136,11 +141,13 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [drawing.id, drawing.preview, ensureFullData, onPreviewGenerated]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [drawing.id, drawing.preview, onPreviewGenerated]); // ensureFullData has stable identity via refs
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
try { try {
setIsExporting(true); setIsExporting(true);
setExportError(null);
const data = await ensureFullData(); const data = await ensureFullData();
const drawingPayload: Drawing = { const drawingPayload: Drawing = {
...drawing, ...drawing,
@@ -151,6 +158,9 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
exportDrawingToFile(drawingPayload); exportDrawingToFile(drawingPayload);
} catch (error) { } catch (error) {
console.error("Failed to export drawing", error); console.error("Failed to export drawing", error);
setExportError("Failed to export drawing. Please try again.");
// Clear error after 3 seconds
setTimeout(() => setExportError(null), 3000);
} finally { } finally {
setIsExporting(false); setIsExporting(false);
} }
@@ -409,6 +419,11 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
{isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />} {isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
{isExporting ? 'Exporting...' : 'Export'} {isExporting ? 'Exporting...' : 'Export'}
</button> </button>
{exportError && (
<div className="px-3 py-2 text-xs text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20">
{exportError}
</div>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div> <div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>