From c916e42572b753a94c04b0fbd88702c3fde26c3e Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 19 Sep 2026 11:39:19 +0200 Subject: [PATCH] perf(studio): speed up nitro scan and stop the cleanup panel freezing --- .../admin/studio/nitro-cleanup-panel.tsx | 104 +++++++++++------- src/lib/services/nitro-cleanup.ts | 15 ++- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/src/components/admin/studio/nitro-cleanup-panel.tsx b/src/components/admin/studio/nitro-cleanup-panel.tsx index a7c54138..0a4cea2d 100644 --- a/src/components/admin/studio/nitro-cleanup-panel.tsx +++ b/src/components/admin/studio/nitro-cleanup-panel.tsx @@ -63,6 +63,13 @@ type AssetKind = "nitro" | "swf" | "icon"; const DAY_MS = 24 * 60 * 60 * 1000; +/** + * How many rows a group renders at once. Directories can contain tens of + * thousands of leftover files; mounting every row as DOM nodes freezes the + * tab, so rows are added in chunks via a "load more" button. + */ +const RENDER_CHUNK = 500; + function ageLabel(lastModified: number): string { if (!lastModified) return "age unknown"; const days = Math.floor((Date.now() - lastModified) / DAY_MS); @@ -103,8 +110,11 @@ function CleanupGroup({ onToggle: (fileName: string) => void; onToggleAll: (names: string[]) => void; }) { + const [shown, setShown] = useState(RENDER_CHUNK); + const checked = entries.filter((e) => selected.has(e.fileName)); const allChecked = entries.length > 0 && checked.length === entries.length; + const visibleEntries = entries.slice(0, shown); const toneClass = tone === "error" ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" @@ -148,45 +158,63 @@ function CleanupGroup({ No {label.toLowerCase()} found

) : ( - + onClick={() => setShown((s) => s + RENDER_CHUNK)} + > + Load {Math.min(RENDER_CHUNK, entries.length - shown)} more + + + )} + )} ); diff --git a/src/lib/services/nitro-cleanup.ts b/src/lib/services/nitro-cleanup.ts index 5fda1c97..2efe4321 100644 --- a/src/lib/services/nitro-cleanup.ts +++ b/src/lib/services/nitro-cleanup.ts @@ -217,14 +217,21 @@ interface DirectoryRead { lastModified: number; } +/** + * How many `stat` calls run in parallel while reading an asset directory. + * Directories can hold hundreds of thousands of files; serial `stat`ing them + * is the single biggest cost of the scan, so the work is spread over a pool. + */ +const DIR_STAT_CONCURRENCY = 32; + async function readAssetDir( dir: string, regex: RegExp, ): Promise { const names = await fs.readdir(dir).catch(() => [] as string[]); const entries: DirectoryRead[] = []; - for (const name of names) { - if (!regex.test(name)) continue; + await runPool(names, DIR_STAT_CONCURRENCY, async (name) => { + if (!regex.test(name)) return; let size = 0; let mtimeMs = 0; try { @@ -240,7 +247,7 @@ async function readAssetDir( size, lastModified: mtimeMs, }); - } + }); return entries; } @@ -321,7 +328,7 @@ export async function scanFakeBrokenNitros(): Promise { const fake: NitroCleanupEntry[] = []; const broken: NitroCleanupEntry[] = []; - await runPool(entries, 8, async (entry) => { + await runPool(entries, 16, async (entry) => { // A .nitro that no DB item maps to is a leftover / fake bundle. if (!validStems.has(entry.base.toLowerCase())) { fake.push(entry);