perf(library): persistent SPA shell, in-memory folder tree caching, and zero-flicker LRU thumbnail caching

This commit is contained in:
2026-10-07 12:05:58 +07:00
parent 3b4c49c41d
commit 1c7be6dfd4
3 changed files with 98 additions and 43 deletions
+63 -37
View File
@@ -7,6 +7,7 @@ import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, rawThumbnail } from './engine/rawDevelop'; import { isRawName, rawThumbnail } from './engine/rawDevelop';
import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import { import {
cachedFolders,
cachedPhotos, cachedPhotos,
canBrowseFolders, canBrowseFolders,
clearLibraryCache, clearLibraryCache,
@@ -197,7 +198,7 @@ export function Library() {
function Catalogue() { function Catalogue() {
const { t } = useI18n(); const { t } = useI18n();
const [folders, setFolders] = useState<LibraryFolder[]>([]); const [folders, setFolders] = useState<LibraryFolder[]>(() => cachedFolders());
const [ready, setReady] = useState<Set<string>>(new Set()); const [ready, setReady] = useState<Set<string>>(new Set());
// The strip starts on the catalogue the last screen read: coming back from the // The strip starts on the catalogue the last screen read: coming back from the
// studio is a screen opening, not a page loading, and the frames the reader was // studio is a screen opening, not a page loading, and the frames the reader was
@@ -2177,58 +2178,83 @@ function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick
} }
// One object URL per tile, made when the row does and dropped with it: a folder // One object URL per tile, made when the row does and dropped with it: a folder
// of thousands cannot hold thousands of live URLs, and the list the panels paint is const globalThumbUrls = new Map<string, { url: string; blob: Blob }>();
// the only list to make them for. `also` is a frame drawn outside that list — the const GLOBAL_URL_MAX = 2500;
// one on the stage, whose tile may be nowhere near the strip's window.
function cacheObjectUrl(id: string, blob: Blob): string {
const existing = globalThumbUrls.get(id);
if (existing && existing.blob === blob) {
return existing.url;
}
if (existing) {
URL.revokeObjectURL(existing.url);
} else if (globalThumbUrls.size >= GLOBAL_URL_MAX) {
const oldestKey = globalThumbUrls.keys().next().value;
if (oldestKey) {
const item = globalThumbUrls.get(oldestKey);
if (item) URL.revokeObjectURL(item.url);
globalThumbUrls.delete(oldestKey);
}
}
const url = URL.createObjectURL(blob);
globalThumbUrls.set(id, { url, blob });
return url;
}
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> { function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({}); const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
const made = useRef(new Map<string, { url: string; blob: Blob }>());
const [urls, setUrls] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const p of list) {
const cached = globalThumbUrls.get(p.id);
if (cached) initial[p.id] = cached.url;
}
return initial;
});
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos;
if (!list.length) { if (!list.length) {
setUrls({}); setUrls({});
return; return;
} }
const ids = list.map((p) => p.id);
void getPhotoThumbs(ids).then((thumbMap) => { const syncUrls: Record<string, string> = {};
const idsToFetch: string[] = [];
for (const p of list) {
const cached = globalThumbUrls.get(p.id);
if (cached) {
syncUrls[p.id] = cached.url;
} else {
idsToFetch.push(p.id);
}
}
if (Object.keys(syncUrls).length > 0) {
setUrls((prev) => ({ ...prev, ...syncUrls }));
}
if (idsToFetch.length === 0) return;
void getPhotoThumbs(idsToFetch).then((thumbMap) => {
if (!alive) return; if (!alive) return;
const next: Record<string, string> = {}; setUrls((prev) => {
for (const photo of list) { const next = { ...prev };
const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null; for (const photo of list) {
if (!blob) continue; const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
const had = made.current.get(photo.id); if (blob) {
if (had && had.blob === blob) { next[photo.id] = cacheObjectUrl(photo.id, blob);
next[photo.id] = had.url; }
} else {
if (had) URL.revokeObjectURL(had.url);
const url = URL.createObjectURL(blob);
made.current.set(photo.id, { url, blob });
next[photo.id] = url;
} }
} return next;
for (const [id, item] of made.current) { });
if (id in next) continue;
URL.revokeObjectURL(item.url);
made.current.delete(id);
}
setUrls(next);
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [photos, also]); }, [list]);
useEffect(
() => () => {
for (const item of made.current.values()) URL.revokeObjectURL(item.url);
made.current.clear();
},
[]
);
return urls; return urls;
} }
+10 -4
View File
@@ -146,7 +146,7 @@ export function canBrowseFolders(): boolean {
return typeof window !== 'undefined' && 'showDirectoryPicker' in window; return typeof window !== 'undefined' && 'showDirectoryPicker' in window;
} }
const THUMB_CACHE_MAX = 500; const THUMB_CACHE_MAX = 5000;
const thumbCache = new Map<string, Blob>(); const thumbCache = new Map<string, Blob>();
export function cacheThumb(id: string, blob: Blob): void { export function cacheThumb(id: string, blob: Blob): void {
@@ -278,14 +278,20 @@ function ask<T>(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore
); );
} }
// --- folders --------------------------------------------------------------- let cachedFoldersList: LibraryFolder[] | null = null;
export function cachedFolders(): LibraryFolder[] {
return cachedFoldersList ?? [];
}
export async function listFolders(): Promise<LibraryFolder[]> { export async function listFolders(): Promise<LibraryFolder[]> {
try { try {
const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll()); const rows = await ask<LibraryFolder[]>(FOLDERS, 'readonly', (s) => s.getAll());
return rows.sort((a, b) => a.name.localeCompare(b.name)); const sorted = rows.sort((a, b) => a.name.localeCompare(b.name));
cachedFoldersList = sorted;
return sorted;
} catch { } catch {
return []; return cachedFoldersList ?? [];
} }
} }
+25 -2
View File
@@ -37,7 +37,6 @@ function pick(route: string) {
); );
} }
// Intercept internal link clicks for instant SPA transitions without full page reload
function Shell() { function Shell() {
const [route, setRoute] = useState(() => window.location.pathname + window.location.search); const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
@@ -64,7 +63,31 @@ function Shell() {
}; };
}, []); }, []);
return pick(route); const isApp = route.startsWith('/app');
const isLibrary = route.startsWith('/library');
const isOther = !isApp && !isLibrary;
return (
<>
<div style={{ display: isApp ? 'block' : 'none', height: '100%' }}>
<Workspace />
</div>
<div style={{ display: isLibrary ? 'block' : 'none', height: '100%' }}>
<Library />
</div>
{isOther ? (
route.startsWith('/admin') ? (
<Admin />
) : route.startsWith('/photos') ? (
<PhotosPage />
) : route.startsWith('/profile') ? (
<ProfilePage />
) : (
<Landing />
)
) : null}
</>
);
} }
const root = document.getElementById('root'); const root = document.getElementById('root');