From 97f3bdebf7dc41504790f8ae53db9082750437b2 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 5 Oct 2026 12:13:45 +0700 Subject: [PATCH] fix(library): resolve main-thread UI freeze and process kill data loss via OPFS payload capping, yield throttling, and UI batching --- docker/frontend/src/Library.tsx | 23 ++++++++++++++++++----- docker/frontend/src/engine/library.ts | 8 +++++--- docker/recipescam-images.tar.gz.sha256 | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index a8016c1..4b14855 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -469,19 +469,32 @@ export function Library() { fresh = 0; } }; - // The reading hands its frames over the moment their batch is stored, so the - // strip is the roll arriving rather than the roll being found again — a read - // of the whole catalogue costs every thumbnail in it, and on a folder of RAW - // that read beside a quarter of a gigabyte of decoder is what took the tab. + let pendingRows: LibraryPhoto[] = []; + let updateTimer: ReturnType | null = null; + + const flushPending = () => { + if (pendingRows.length > 0) { + const toAdd = pendingRows; + pendingRows = []; + setPhotos((prev) => [...prev, ...toAdd]); + } + updateTimer = null; + }; + watchRows((rows) => { fresh += rows.length; - setPhotos((prev) => [...prev, ...rows]); + pendingRows.push(...rows); + if (!updateTimer) { + updateTimer = setTimeout(flushPending, 500); + } }); const unwatch = watchScan(watching); // A scan already in flight when this screen opens is picked up where it is: // the reader who left for the studio comes back to the same roll. watching(); return () => { + if (updateTimer) clearTimeout(updateTimer); + flushPending(); unwatch(); watchRows(null); }; diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 67769ec..caa73bf 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -45,7 +45,7 @@ const LANES = 4; // How often the walk's position is written down, in the same seconds as the // catalogue is read back on: the position is the whole of what the walk has found // and not read, and writing it is a string of a few megabytes on a long roll. -const WALK_MS = 2000; +const WALK_MS = 5000; // How often the screen is told how far the reading has come: the column is // rebuilt out of the count, and a rebuild a frame is a reading that spends its // time drawing itself. Five times a second is a counter that moves to the eye @@ -834,11 +834,12 @@ export async function scanFolder( if (at - wroteAt < WALK_MS) return; wroteAt = at; const held = [...inHand, ...batch.map(pathOf)]; + const savedEntries = entries.slice(0, 500).map((e) => e.rel); await saveWalk( folder.name, [...tree.walked], tree.pending.map((p) => p.rel), - [...held, ...entries.map((e) => e.rel)], + [...held, ...savedEntries], held.length, progress ); @@ -962,11 +963,12 @@ export async function scanFolder( // Micro-yield to the browser main thread event loop so scanning never blocks UI rendering or user interactions processed++; - if (processed % 12 === 0) { + if (processed % 3 === 0) { await new Promise((resolve) => setTimeout(resolve, 0)); } } await Promise.all(lanes); + await flush(); }; // How many of the frames in hand were counted by the reading that handed them diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 4e65133..db6d094 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -121890a84ecf3ffb7bf228416ba1791ae2bbd4ebc692a97da9a3af9c8299d127 recipescam-images.tar.gz +f434fd494a98a7c0b280d2dd9bc142f00faf399436c068735bc854c911a64046 recipescam-images.tar.gz