From b92f897ba217e82a75eecf7c8b89094fa707bf82 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 13 Sep 2026 11:53:07 +0200 Subject: [PATCH] perf(studio): virtualize the furniture table and throttle batch progress - Render the table view through a virtualizer too, using a shared grid template so the sticky header and rows keep perfect column alignment - Keep semantic table/row/cell elements while virtualizing - Cap the batch item-details list to the latest 60 rows (newest first) - Coalesce per-item progress events server-side (120ms throttle) in both the exact-import and clone SSE batch runners --- src/app/api/admin/import/furni/batch/route.ts | 8 + .../admin/studio/batch-progress.tsx | 15 +- src/components/admin/studio/studio-client.tsx | 448 ++++++++++-------- src/lib/services/import/core/sse-batch.ts | 19 +- 4 files changed, 284 insertions(+), 206 deletions(-) diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index d74b8461..dc571216 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -117,6 +117,11 @@ export const POST = withAdmin( }); try { + // Coalesce micro-step progress events to at most one per + // ~120ms per item so large imports don't flood the client + // (terminal states are always emitted by importSingleFurni + // and sent below). + let lastProgressSent = 0; const result: ImportSingleResult = await importSingleFurni({ id: item.id ?? 0, classname: item.classname, @@ -131,6 +136,9 @@ export const POST = withAdmin( nitroBaseUrl: source?.nitroBaseUrl, iconBaseUrl: source?.iconBaseUrl, onProgress: (status: string) => { + const now = Date.now(); + if (now - lastProgressSent < 120) return; + lastProgressSent = now; send({ type: "item_progress", classname: item.classname, diff --git a/src/components/admin/studio/batch-progress.tsx b/src/components/admin/studio/batch-progress.tsx index e7e7677c..8489fbf5 100644 --- a/src/components/admin/studio/batch-progress.tsx +++ b/src/components/admin/studio/batch-progress.tsx @@ -73,7 +73,13 @@ export const BatchProgress = memo(function BatchProgress({ return () => clearInterval(id); }, [done]); - const entries = useMemo(() => [...progress.values()], [progress]); + // Relevance-first list: newest activity on top, capped so high-volume + // imports don't render hundreds of DOM rows on every progress event. + const VISIBLE_ITEMS = 60; + const entries = useMemo( + () => [...progress.values()].slice().reverse(), + [progress], + ); const completed = useMemo( () => entries.filter((i) => i.status === "done" || i.status === "failed") @@ -239,7 +245,12 @@ export const BatchProgress = memo(function BatchProgress({ View item details ({total})
- {entries.map((item) => ( + {entries.length > VISIBLE_ITEMS && ( +

+ Showing {VISIBLE_ITEMS} of {entries.length} items +

+ )} + {entries.slice(0, VISIBLE_ITEMS).map((item) => (
GRID_ROW_H, overscan: 5, }); + const tableVirtualizer = useVirtualizer({ + count: viewMode === "list" ? filteredItems.length : 0, + getScrollElement: () => listRef.current, + estimateSize: () => TABLE_ROW_H, + overscan: 8, + }); // Infinite scroll: auto-load the next page when the sentinel enters view. const sentinelRef = useRef(null); @@ -1771,9 +1783,9 @@ export function StudioClient({

{item.classname}

-

- {item.category || "—"} -

+

+ {item.category || "—"} +

@@ -1855,212 +1867,246 @@ export function StudioClient({ })}
) : ( -
- - - - - - - - - - - - - - {filteredItems.map((item) => { - const isSel = selected.has(item.classname); - return ( - - + + + ); + })} + +
- - - - Name - - Classname - - Type - - Category - - Status - - Actions -
- - toggleSelect(item.classname) - } - aria-label={`Select ${item.classname}`} + + + + + + + + + + + + + + {tableVirtualizer.getVirtualItems().map((vi) => { + const item = filteredItems[vi.index]; + const isSel = selected.has(item.classname); + return ( + + + - - - - - - - + + + + + - - ); - })} - -
+ + + + Name + + Classname + + Type + + Category + + Status + + Actions +
+ + toggleSelect(item.classname) + } + aria-label={`Select ${item.classname}`} + /> + + - - - {item.name} - - {item.classname} - - {TYPE_LABELS[item.type] ?? item.type} - - {item.category || "—"} - - {item.alreadyImported ? ( -
- {!item.nitroExists && ( - - No nitro - - )} - {item.hasFurnidata === false && ( - - No furnidata - - )} - {item.hasCatalogEntry === false && ( - - No catalog - - )} - {item.nitroExists && - item.hasFurnidata !== false && - item.hasCatalogEntry !== false && ( - - Imported - - )} -
- ) : ( - - New - - )} -
-
- {!item.alreadyImported && ( - +
+ {item.name} + + {item.classname} + + {TYPE_LABELS[item.type] ?? item.type} + + {item.category || "—"} + + {item.alreadyImported ? ( +
+ {!item.nitroExists && ( + - {importingId === item.classname ? ( - - ) : ( - - )} - + No nitro + )} - {item.alreadyImported && item.nitroExists && ( - + No furnidata + )} - {item.alreadyImported && - !item.nitroExists && ( - + Imported + )}
-
- + ) : ( + + New + + )} +
+
+ {!item.alreadyImported && ( + + )} + {item.alreadyImported && item.nitroExists && ( + + )} + {item.alreadyImported && !item.nitroExists && ( + + )} +
+
)}
(opts: RunSseBatchOptions): Response { index, }); try { - const result = await worker(item, index, (status) => - send({ type: "item_progress", classname, status, index }), - ); + // Coalesce micro-step progress ('downloading', 'converting', + // ...) to at most one per 120ms per item so large imports + // don't flood the client. Terminal states are emitted + // separately below. + let lastReportSent = 0; + const result = await worker(item, index, (status) => { + const now = Date.now(); + if (now - lastReportSent < 120) return; + lastReportSent = now; + send({ + type: "item_progress", + classname, + status, + index, + }); + }); if (ac.signal.aborted) return; if (result.ok) { succeeded++;