perf: optimize drawings endpoint with caching and lazy loading
- Add 5s in-memory cache for /drawings responses with automatic cleanup - Split Drawing/DrawingSummary types for efficient data fetching - Implement lazy loading of drawing data in DrawingCard component - Add configurable DRAWINGS_CACHE_TTL_MS and RATE_LIMIT_MAX_REQUESTS env vars - Prevent memory leaks with periodic cleanup of cache and rate limit maps - Add loading states and better UX for export operations - Improve JSON parsing with error handling for malformed stored data Benchmark results (100 drawings, cached): - Avg latency: 6.94ms (p50: 4ms, p97.5: 8ms) - Avg throughput: 668 req/s (peak: 1,023) - 3k requests in 5s with 0 errors Update .gitignore to exclude generated files, env files, and build artifacts
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download } from 'lucide-react';
|
||||
import type { Drawing, Collection } from '../types';
|
||||
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, Loader2 } from 'lucide-react';
|
||||
import type { DrawingSummary, Collection, Drawing } from '../types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
@@ -11,7 +11,7 @@ import { exportDrawingToFile } from '../utils/exportUtils';
|
||||
import * as api from '../api';
|
||||
|
||||
interface DrawingCardProps {
|
||||
drawing: Drawing;
|
||||
drawing: DrawingSummary;
|
||||
collections: Collection[];
|
||||
isSelected: boolean;
|
||||
isTrash?: boolean;
|
||||
@@ -49,30 +49,57 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
||||
const [showCollectionDropdown, setShowCollectionDropdown] = useState(false);
|
||||
const [newName, setNewName] = useState(drawing.name);
|
||||
const [previewSvg, setPreviewSvg] = useState<string | null>(null);
|
||||
const [previewSvg, setPreviewSvg] = useState<string | null>(drawing.preview ?? null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [fullData, setFullData] = useState<{
|
||||
elements: any[];
|
||||
appState: any;
|
||||
files: Record<string, any> | null;
|
||||
} | null>(null);
|
||||
|
||||
const fullDataRef = React.useRef(fullData);
|
||||
fullDataRef.current = fullData;
|
||||
|
||||
const ensureFullData = useCallback(async () => {
|
||||
if (fullDataRef.current) {
|
||||
return fullDataRef.current;
|
||||
}
|
||||
const fullDrawing = await api.getDrawing(drawing.id);
|
||||
const payload = {
|
||||
elements: fullDrawing.elements || [],
|
||||
appState: fullDrawing.appState || {},
|
||||
files: fullDrawing.files || {},
|
||||
};
|
||||
setFullData(payload);
|
||||
return payload;
|
||||
}, [drawing.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (drawing.preview) {
|
||||
setPreviewSvg(drawing.preview);
|
||||
return;
|
||||
}
|
||||
|
||||
const generatePreview = async () => {
|
||||
// Ensure elements and appState exist before trying to generate
|
||||
if (!drawing.elements || !drawing.appState) return;
|
||||
|
||||
try {
|
||||
const data = await ensureFullData();
|
||||
if (cancelled) return;
|
||||
if (!data?.elements || !data?.appState) return;
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: drawing.elements,
|
||||
elements: data.elements,
|
||||
appState: {
|
||||
...drawing.appState,
|
||||
...data.appState,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: drawing.appState.viewBackgroundColor || "#ffffff"
|
||||
viewBackgroundColor: data.appState.viewBackgroundColor || "#ffffff"
|
||||
},
|
||||
files: drawing.files || {},
|
||||
files: data.files || {},
|
||||
exportPadding: 10
|
||||
});
|
||||
if (cancelled) return;
|
||||
const previewHtml = svg.outerHTML;
|
||||
setPreviewSvg(previewHtml);
|
||||
|
||||
@@ -80,11 +107,37 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
|
||||
onPreviewGenerated?.(drawing.id, previewHtml);
|
||||
} catch (e) {
|
||||
console.error("Failed to generate preview", e);
|
||||
if (!cancelled) {
|
||||
console.error("Failed to generate preview", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generatePreview();
|
||||
}, [drawing, onPreviewGenerated]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [drawing.id, drawing.preview, ensureFullData, onPreviewGenerated]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
const data = await ensureFullData();
|
||||
const drawingPayload: Drawing = {
|
||||
...drawing,
|
||||
elements: data.elements || [],
|
||||
appState: data.appState || {},
|
||||
files: data.files || {},
|
||||
};
|
||||
exportDrawingToFile(drawingPayload);
|
||||
} catch (error) {
|
||||
console.error("Failed to export drawing", error);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [drawing, ensureFullData]);
|
||||
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
@@ -327,13 +380,16 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
exportDrawingToFile(drawing);
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await handleExport();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
|
||||
disabled={isExporting}
|
||||
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download size={14} /> Export
|
||||
{isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
|
||||
{isExporting ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
|
||||
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
|
||||
|
||||
Reference in New Issue
Block a user