From a1ec25a441d7f1f01be048df53a82d9192680054 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Wed, 10 Jun 2026 15:18:57 +0300 Subject: [PATCH] perf(frontend): lazy-load file tags on scroll into view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file viewer fetched /files/:id/tags eagerly alongside the file on open, even though the Tags section sits below a full-viewport preview and is usually never seen — needless DB load per file open. Defer the tags fetch until the Tags section scrolls into view via an IntersectionObserver (200px rootMargin pre-load). Re-fetches when paging to another file while the section stays on-screen; shows a "Loading tags…" placeholder until loaded. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/files/[id]/+page.svelte | 78 ++++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/frontend/src/routes/files/[id]/+page.svelte b/frontend/src/routes/files/[id]/+page.svelte index c490cad..d32bf96 100644 --- a/frontend/src/routes/files/[id]/+page.svelte +++ b/frontend/src/routes/files/[id]/+page.svelte @@ -23,6 +23,14 @@ let saving = $state(false); let error = $state(''); + // Tags are loaded lazily — the Tags section sits below a full-viewport + // preview, so fetching them on open just hammers the DB for data the user + // usually never scrolls to. We fetch only once the section comes into view. + let tagsVisible = $state(false); + let tagsLoading = $state(false); + let tagsLoadedFor = $state(null); + let tagsLoaded = $derived(tagsLoadedFor === fileId); + // Editable fields (initialised on load) let notes = $state(''); let contentDatetime = $state(''); @@ -48,13 +56,11 @@ async function loadPage(id: string) { loading = true; error = ''; + // Drop the previous file's tags; they reload lazily when scrolled to. + fileTags = []; try { - const [fileData, tags] = await Promise.all([ - api.get(`/files/${id}`), - api.get(`/files/${id}/tags`), - ]); + const fileData = await api.get(`/files/${id}`); file = fileData; - fileTags = tags; notes = fileData.notes ?? ''; contentDatetime = fileData.content_datetime ? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm @@ -152,10 +158,52 @@ } } - // ---- Tags ---- + // ---- Tags (lazy) ---- + // Fetch the current file's tags the first time the Tags section is visible. + // Re-runs when fileId changes while the section is still on-screen (e.g. + // keyboard paging while scrolled down). + $effect(() => { + const id = fileId; + if (id && tagsVisible && tagsLoadedFor !== id && !tagsLoading) { + void loadTags(id); + } + }); + + async function loadTags(id: string) { + tagsLoading = true; + try { + const tags = await api.get(`/files/${id}/tags`); + if (page.params.id !== id) return; // user navigated on; ignore + fileTags = tags; + tagsLoadedFor = id; + } catch { + // non-critical — a later scroll into view retries + } finally { + tagsLoading = false; + } + } + + // Svelte action: flips tagsVisible while the Tags section is in (or near) the + // viewport. rootMargin pre-loads just before it scrolls fully into view. + function tagsSentinel(node: HTMLElement) { + const observer = new IntersectionObserver( + (entries) => { + tagsVisible = entries[0]?.isIntersecting ?? false; + }, + { rootMargin: '200px' }, + ); + observer.observe(node); + return { + destroy() { + observer.disconnect(); + }, + }; + } + async function addTag(tagId: string) { const updated = await api.put(`/files/${fileId}/tags/${tagId}`); fileTags = updated; + tagsLoadedFor = fileId ?? null; } async function removeTag(tagId: string) { @@ -307,10 +355,14 @@ {saving ? 'Saving…' : 'Save changes'} - -
+ +
Tags
- + {#if tagsLoaded} + + {:else} +

Loading tags…

+ {/if}
@@ -585,6 +637,14 @@ cursor: default; } + /* ---- Tags ---- */ + .tags-loading { + margin: 0; + font-size: 0.8rem; + color: var(--color-text-muted); + opacity: 0.7; + } + /* ---- EXIF ---- */ .exif { display: grid;