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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
);
|
||||
}
|
||||
|
||||
invalidateSectionCache(path, (init?.method ?? 'GET').toUpperCase());
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<T> {
|
||||
/** sort|order|search at capture — guards against restoring a different query. */
|
||||
resetKey: string;
|
||||
search: string;
|
||||
items: T[];
|
||||
total: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
interface Snapshot<T> {
|
||||
/** Scroll offset of the list's scroller at capture time. */
|
||||
scrollTop: number;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { get } from 'svelte/store';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
|
||||
import { restoreListScroll } from '$lib/stores/listScroll';
|
||||
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 100;
|
||||
@@ -26,6 +29,44 @@
|
||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||
let prevKey = $state('');
|
||||
|
||||
let scrollEl = $state<HTMLElement>();
|
||||
let pendingScroll: number | null = null;
|
||||
|
||||
// Rehydrate the loaded list, search and scroll from the cache on return (same
|
||||
// sort/order/search), during init so the matching prevKey/initialLoaded
|
||||
// suppress the reset + initial load below.
|
||||
const cached = takeSection<OffsetListSnapshot<Category>>('categories');
|
||||
if (cached) {
|
||||
const s0 = get(categorySorting);
|
||||
const wouldKey = `${s0.sort}|${s0.order}|${cached.data.search}`;
|
||||
if (wouldKey === cached.data.resetKey && cached.data.items.length > 0) {
|
||||
search = cached.data.search;
|
||||
categories = cached.data.items;
|
||||
total = cached.data.total;
|
||||
offset = cached.data.offset;
|
||||
initialLoaded = true;
|
||||
prevKey = wouldKey;
|
||||
pendingScroll = cached.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
if (categories.length === 0) return;
|
||||
saveSection<OffsetListSnapshot<Category>>('categories', scrollEl?.scrollTop ?? 0, {
|
||||
resetKey,
|
||||
search,
|
||||
items: categories,
|
||||
total,
|
||||
offset
|
||||
});
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (pendingScroll == null) return;
|
||||
restoreListScroll(() => scrollEl, pendingScroll);
|
||||
pendingScroll = null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (resetKey !== prevKey) {
|
||||
prevKey = resetKey;
|
||||
@@ -146,7 +187,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<main bind:this={scrollEl}>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { get } from 'svelte/store';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
|
||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
|
||||
import { restoreListScroll } from '$lib/stores/listScroll';
|
||||
import type { Pool, PoolOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 50;
|
||||
@@ -25,6 +28,44 @@
|
||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||
let prevKey = $state('');
|
||||
|
||||
let scrollEl = $state<HTMLElement>();
|
||||
let pendingScroll: number | null = null;
|
||||
|
||||
// Rehydrate the loaded list, search and scroll from the cache on return (same
|
||||
// sort/order/search), during init so the matching prevKey/initialLoaded
|
||||
// suppress the reset + initial load below.
|
||||
const cached = takeSection<OffsetListSnapshot<Pool>>('pools');
|
||||
if (cached) {
|
||||
const s0 = get(poolSorting);
|
||||
const wouldKey = `${s0.sort}|${s0.order}|${cached.data.search}`;
|
||||
if (wouldKey === cached.data.resetKey && cached.data.items.length > 0) {
|
||||
search = cached.data.search;
|
||||
pools = cached.data.items;
|
||||
total = cached.data.total;
|
||||
offset = cached.data.offset;
|
||||
initialLoaded = true;
|
||||
prevKey = wouldKey;
|
||||
pendingScroll = cached.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
if (pools.length === 0) return;
|
||||
saveSection<OffsetListSnapshot<Pool>>('pools', scrollEl?.scrollTop ?? 0, {
|
||||
resetKey,
|
||||
search,
|
||||
items: pools,
|
||||
total,
|
||||
offset
|
||||
});
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (pendingScroll == null) return;
|
||||
restoreListScroll(() => scrollEl, pendingScroll);
|
||||
pendingScroll = null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (resetKey !== prevKey) {
|
||||
prevKey = resetKey;
|
||||
@@ -147,7 +188,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<main bind:this={scrollEl}>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { get } from 'svelte/store';
|
||||
import { api, ApiError } from '$lib/api/client';
|
||||
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
|
||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
|
||||
import { restoreListScroll } from '$lib/stores/listScroll';
|
||||
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 100;
|
||||
@@ -30,6 +33,45 @@
|
||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||
let prevKey = $state('');
|
||||
|
||||
let scrollEl = $state<HTMLElement>();
|
||||
let pendingScroll: number | null = null;
|
||||
|
||||
// Returning from another section: rehydrate the loaded list, search and scroll
|
||||
// from the cache instead of refetching, as long as the snapshot was taken under
|
||||
// the same sort/order/search. Done during init (before the effects below run)
|
||||
// so the matching prevKey/initialLoaded suppress the reset + initial load.
|
||||
const cached = takeSection<OffsetListSnapshot<Tag>>('tags');
|
||||
if (cached) {
|
||||
const s0 = get(tagSorting);
|
||||
const wouldKey = `${s0.sort}|${s0.order}|${cached.data.search}`;
|
||||
if (wouldKey === cached.data.resetKey && cached.data.items.length > 0) {
|
||||
search = cached.data.search;
|
||||
tags = cached.data.items;
|
||||
total = cached.data.total;
|
||||
offset = cached.data.offset;
|
||||
initialLoaded = true;
|
||||
prevKey = wouldKey;
|
||||
pendingScroll = cached.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
if (tags.length === 0) return;
|
||||
saveSection<OffsetListSnapshot<Tag>>('tags', scrollEl?.scrollTop ?? 0, {
|
||||
resetKey,
|
||||
search,
|
||||
items: tags,
|
||||
total,
|
||||
offset
|
||||
});
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (pendingScroll == null) return;
|
||||
restoreListScroll(() => scrollEl, pendingScroll);
|
||||
pendingScroll = null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (resetKey !== prevKey) {
|
||||
prevKey = resetKey;
|
||||
@@ -155,7 +197,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<main bind:this={scrollEl}>
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user