From 2b39af8c1cc6a1f3d97fdaeb37c50dc024929ce0 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 17:20:48 +0300 Subject: [PATCH] feat(frontend): cache Tags/Categories/Pools lists across section switches The offset-paginated lists lost their loaded items, search text and scroll position when you left for another section, since search is local (not in the URL) and the page unmounts on navigation. Each now snapshots that state on departure and rehydrates it on return when the sort/order/search still match, restoring scroll after the list paints. Because these lists are edited on their own detail/new pages, the API client drops the matching section's snapshot on any successful mutation so a stale list never restores. Shared scroll-restore helper and an OffsetListSnapshot type keep the three pages in lockstep. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/api/client.ts | 19 +++++++++ frontend/src/lib/stores/listScroll.ts | 19 +++++++++ frontend/src/lib/stores/sectionCache.ts | 10 +++++ frontend/src/routes/categories/+page.svelte | 45 +++++++++++++++++++- frontend/src/routes/pools/+page.svelte | 45 +++++++++++++++++++- frontend/src/routes/tags/+page.svelte | 46 ++++++++++++++++++++- 6 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/stores/listScroll.ts diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d1a0cd5..01100d6 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -2,9 +2,26 @@ import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import { browser } from '$app/environment'; import { authStore } from '$lib/stores/auth'; +import { clearSection, type SectionKey } from '$lib/stores/sectionCache'; const BASE = '/api/v1'; +// The tags/categories/pools lists are edited on their own detail/new pages, so a +// cached list snapshot goes stale after a write there. Drop the matching +// section's snapshot on any successful mutation so the list refetches on return. +// (Files isn't included — its grid keeps itself consistent via optimistic +// updates, and over-invalidating would needlessly lose the scroll position.) +function invalidateSectionCache(path: string, method: string): void { + if (method === 'GET') return; + const sections: SectionKey[] = ['tags', 'categories', 'pools']; + for (const s of sections) { + if (path === `/${s}` || path.startsWith(`/${s}/`) || path.startsWith(`/${s}?`)) { + clearSection(s); + return; + } + } +} + /** Clear the session and bounce to the login screen. Called when the refresh * token is missing or rejected, so an expired session doesn't strand the user * on a page that only shows errors. */ @@ -104,6 +121,8 @@ async function request(path: string, init?: RequestInit): Promise { ); } + invalidateSectionCache(path, (init?.method ?? 'GET').toUpperCase()); + if (res.status === 204) return undefined as T; return res.json(); } diff --git a/frontend/src/lib/stores/listScroll.ts b/frontend/src/lib/stores/listScroll.ts new file mode 100644 index 0000000..5475b8e --- /dev/null +++ b/frontend/src/lib/stores/listScroll.ts @@ -0,0 +1,19 @@ +// Reapply a restored scroll offset to a list's scroller, retrying across frames +// because the list may not be laid out yet right after a cache rehydrate (and +// SvelteKit resets scroll to the top on navigation, so this has to win after). +export function restoreListScroll(getEl: () => HTMLElement | undefined, top: number): void { + let tries = 12; + const apply = () => { + const el = getEl(); + if (!el) { + if (tries-- > 0) requestAnimationFrame(apply); + return; + } + if (el.scrollHeight > top + el.clientHeight || tries-- <= 0) { + el.scrollTop = top; + return; + } + requestAnimationFrame(apply); + }; + requestAnimationFrame(apply); +} diff --git a/frontend/src/lib/stores/sectionCache.ts b/frontend/src/lib/stores/sectionCache.ts index be7b3ff..ea5eda1 100644 --- a/frontend/src/lib/stores/sectionCache.ts +++ b/frontend/src/lib/stores/sectionCache.ts @@ -10,6 +10,16 @@ export type SectionKey = 'files' | 'tags' | 'categories' | 'pools'; +/** Snapshot shape shared by the offset-paginated lists (tags/categories/pools). */ +export interface OffsetListSnapshot { + /** sort|order|search at capture — guards against restoring a different query. */ + resetKey: string; + search: string; + items: T[]; + total: number; + offset: number; +} + interface Snapshot { /** Scroll offset of the list's scroller at capture time. */ scrollTop: number; diff --git a/frontend/src/routes/categories/+page.svelte b/frontend/src/routes/categories/+page.svelte index b30d659..2007a06 100644 --- a/frontend/src/routes/categories/+page.svelte +++ b/frontend/src/routes/categories/+page.svelte @@ -1,8 +1,11 @@