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:
@@ -30,8 +30,32 @@
|
||||
|
||||
let imgSrc = $state<string | null>(null);
|
||||
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(() => {
|
||||
if (!visible) return;
|
||||
const token = get(authStore).accessToken;
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
@@ -112,6 +136,7 @@
|
||||
class:loaded={!!imgSrc}
|
||||
class:selected
|
||||
class:focused
|
||||
use:lazyload
|
||||
data-file-index={index}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMoveInternal}
|
||||
|
||||
@@ -14,17 +14,43 @@
|
||||
|
||||
let imgSrc = $state<string | null>(null);
|
||||
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
|
||||
// (mirrors FileCard's loader). Re-runs whenever the id changes.
|
||||
// (mirrors FileCard's loader). Runs once visible; re-runs whenever the id changes.
|
||||
$effect(() => {
|
||||
if (!visible) return;
|
||||
const token = get(authStore).accessToken;
|
||||
const currentId = id; // track id so a reused node refetches on change
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
imgSrc = null;
|
||||
failed = false;
|
||||
|
||||
fetch(`/api/v1/files/${id}/thumbnail`, {
|
||||
fetch(`/api/v1/files/${currentId}/thumbnail`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
.then((res) => (res.ok ? res.blob() : null))
|
||||
@@ -47,7 +73,7 @@
|
||||
});
|
||||
</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}
|
||||
<img src={imgSrc} {alt} draggable="false" />
|
||||
{:else if failed}
|
||||
|
||||
Reference in New Issue
Block a user