fix(drawings): stabilize lazy loading, improve export error handling, and tidy cache invalidation
This commit is contained in:
+15
-4
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user