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:
2026-06-11 17:20:48 +03:00
parent 05af819b3e
commit 2b39af8c1c
6 changed files with 178 additions and 6 deletions
+43 -2
View File
@@ -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}
+43 -2
View File
@@ -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}
+44 -2
View File
@@ -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}