perf(frontend): lazy-load thumbnails via IntersectionObserver

Thumb and FileCard fetched their auth-gated thumbnail on mount, so a view
that rendered many tiles at once — notably a duplicate cluster, which can
hold hundreds of files — fired thousands of requests immediately and hung
the tab. Gate each fetch behind an IntersectionObserver that loads only when
the tile nears the viewport (200px rootMargin), then stops observing.
This commit is contained in:
2026-06-22 22:10:12 +03:00
parent 6fe6e4cd55
commit e5a731eb29
2 changed files with 54 additions and 3 deletions
@@ -30,8 +30,32 @@
let imgSrc = $state<string | null>(null); let imgSrc = $state<string | null>(null);
let failed = $state(false); let failed = $state(false);
// Gate the fetch on visibility so a long grid doesn't fire every thumbnail
// request on mount; the tile loads once it scrolls near the viewport.
let visible = $state(false);
// Svelte action: flips `visible` true the first time the card nears the
// viewport, then stops observing — the blob is kept once loaded.
function lazyload(node: HTMLElement) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
visible = true;
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
$effect(() => { $effect(() => {
if (!visible) return;
const token = get(authStore).accessToken; const token = get(authStore).accessToken;
let objectUrl: string | null = null; let objectUrl: string | null = null;
let cancelled = false; let cancelled = false;
@@ -112,6 +136,7 @@
class:loaded={!!imgSrc} class:loaded={!!imgSrc}
class:selected class:selected
class:focused class:focused
use:lazyload
data-file-index={index} data-file-index={index}
onpointerdown={onPointerDown} onpointerdown={onPointerDown}
onpointermove={onPointerMoveInternal} onpointermove={onPointerMoveInternal}
+29 -3
View File
@@ -14,17 +14,43 @@
let imgSrc = $state<string | null>(null); let imgSrc = $state<string | null>(null);
let failed = $state(false); let failed = $state(false);
// Gate the fetch on visibility. A duplicate cluster can hold hundreds of files,
// and firing every thumbnail request on mount buries the server in a request
// storm (10k+ in-flight is easy). We only load once the tile nears the viewport.
let visible = $state(false);
// Svelte action: flips `visible` true the first time the tile nears the
// viewport, then stops observing — the blob is kept once loaded.
function lazyload(node: HTMLElement) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
visible = true;
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
// Thumbnails are auth-gated, so fetch with the bearer token and render the blob // Thumbnails are auth-gated, so fetch with the bearer token and render the blob
// (mirrors FileCard's loader). Re-runs whenever the id changes. // (mirrors FileCard's loader). Runs once visible; re-runs whenever the id changes.
$effect(() => { $effect(() => {
if (!visible) return;
const token = get(authStore).accessToken; const token = get(authStore).accessToken;
const currentId = id; // track id so a reused node refetches on change
let objectUrl: string | null = null; let objectUrl: string | null = null;
let cancelled = false; let cancelled = false;
imgSrc = null; imgSrc = null;
failed = false; failed = false;
fetch(`/api/v1/files/${id}/thumbnail`, { fetch(`/api/v1/files/${currentId}/thumbnail`, {
headers: token ? { Authorization: `Bearer ${token}` } : {} headers: token ? { Authorization: `Bearer ${token}` } : {}
}) })
.then((res) => (res.ok ? res.blob() : null)) .then((res) => (res.ok ? res.blob() : null))
@@ -47,7 +73,7 @@
}); });
</script> </script>
<div class="thumb" style="width:{size}px;height:{size}px"> <div class="thumb" use:lazyload style="width:{size}px;height:{size}px">
{#if imgSrc} {#if imgSrc}
<img src={imgSrc} {alt} draggable="false" /> <img src={imgSrc} {alt} draggable="false" />
{:else if failed} {:else if failed}