feat(frontend): restore the Files grid and scroll on return via section cache
Leaving the Files list for another section unmounted the page and lost the loaded grid, cursors and scroll position; returning refetched page 1 from the top. A new in-memory section cache snapshots that state on departure (beforeNavigate) and rehydrates it on the next mount when the sort/filter still match, reapplying the scroll offset after the grid paints. Combined with the navbar remembering the section URL, tapping back into Files lands you exactly where you left off. The snapshot is session-only, validated by resetKey, and skipped for in-page query changes and the shallow-routed viewer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
// In-memory, per-section view cache. When you leave a list (Files, Tags, …) for
|
||||||
|
// another section and come back, the page restores its loaded items, pagination
|
||||||
|
// cursors and scroll position from here instead of refetching from scratch.
|
||||||
|
//
|
||||||
|
// Kept deliberately simple: a plain module-level Map that lives for the session.
|
||||||
|
// No TTL — a snapshot is taken from the page's current state on the way out, so
|
||||||
|
// it already reflects local mutations (deletes, uploads, tag edits). It is
|
||||||
|
// dropped on a full reload, and each page validates the snapshot's `resetKey`
|
||||||
|
// (sort/filter/search) before trusting it, so a stale query never restores.
|
||||||
|
|
||||||
|
export type SectionKey = 'files' | 'tags' | 'categories' | 'pools';
|
||||||
|
|
||||||
|
interface Snapshot<T> {
|
||||||
|
/** Scroll offset of the list's scroller at capture time. */
|
||||||
|
scrollTop: number;
|
||||||
|
/** Page-specific state blob; opaque to this module. */
|
||||||
|
data: T;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<SectionKey, Snapshot<unknown>>();
|
||||||
|
|
||||||
|
export function saveSection<T>(key: SectionKey, scrollTop: number, data: T): void {
|
||||||
|
cache.set(key, { scrollTop, data, savedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and remove a section's snapshot (restore consumes it). */
|
||||||
|
export function takeSection<T>(key: SectionKey): { scrollTop: number; data: T } | null {
|
||||||
|
const snap = cache.get(key) as Snapshot<T> | undefined;
|
||||||
|
if (!snap) return null;
|
||||||
|
cache.delete(key);
|
||||||
|
return { scrollTop: snap.scrollTop, data: snap.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSection(key: SectionKey): void {
|
||||||
|
cache.delete(key);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { afterNavigate, goto, pushState, replaceState } from '$app/navigation';
|
import { afterNavigate, beforeNavigate, goto, pushState, replaceState } from '$app/navigation';
|
||||||
|
import { saveSection, takeSection } from '$lib/stores/sectionCache';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||||
@@ -19,6 +20,17 @@
|
|||||||
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
||||||
import { appSettings } from '$lib/stores/appSettings';
|
import { appSettings } from '$lib/stores/appSettings';
|
||||||
|
|
||||||
|
// What the section cache stores for the Files grid. `resetKey` guards against
|
||||||
|
// restoring under a different sort/filter than was captured.
|
||||||
|
interface FilesSnapshot {
|
||||||
|
resetKey: string;
|
||||||
|
files: File[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
prevCursor: string | null;
|
||||||
|
hasPrev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
let scrollContainer = $state<HTMLElement | undefined>();
|
let scrollContainer = $state<HTMLElement | undefined>();
|
||||||
|
|
||||||
let uploader = $state<{ open: () => void } | undefined>();
|
let uploader = $state<{ open: () => void } | undefined>();
|
||||||
@@ -113,6 +125,11 @@
|
|||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
||||||
let prevKey = $state('');
|
let prevKey = $state('');
|
||||||
|
|
||||||
|
// Scroll offset to reapply once the restored grid has painted (set when a
|
||||||
|
// cached snapshot is rehydrated; consumed in afterNavigate so it wins over
|
||||||
|
// SvelteKit's own scroll-to-top).
|
||||||
|
let pendingScroll: number | null = null;
|
||||||
|
|
||||||
// Reset + reload when the query (sort/order/filter) changes or on first mount.
|
// Reset + reload when the query (sort/order/filter) changes or on first mount.
|
||||||
// The viewer opens as an overlay now (the list is never unmounted), so there's
|
// The viewer opens as an overlay now (the list is never unmounted), so there's
|
||||||
// no snapshot to restore — except a deep-link return carrying an anchor.
|
// no snapshot to restore — except a deep-link return carrying an anchor.
|
||||||
@@ -122,6 +139,26 @@
|
|||||||
const firstRun = prevKey === '';
|
const firstRun = prevKey === '';
|
||||||
prevKey = key;
|
prevKey = key;
|
||||||
|
|
||||||
|
// Returning to this section: rehydrate the loaded grid + cursors + scroll
|
||||||
|
// from the section cache instead of refetching, as long as the snapshot was
|
||||||
|
// taken under the same sort/filter. Skip when arriving on an anchor, which
|
||||||
|
// has its own (deep-link) restore path below.
|
||||||
|
if (firstRun && !anchorParam) {
|
||||||
|
const snap = takeSection<FilesSnapshot>('files');
|
||||||
|
if (snap && snap.data.resetKey === key && snap.data.files.length > 0) {
|
||||||
|
files = snap.data.files;
|
||||||
|
nextCursor = snap.data.nextCursor;
|
||||||
|
hasMore = snap.data.hasMore;
|
||||||
|
prevCursor = snap.data.prevCursor;
|
||||||
|
hasPrev = snap.data.hasPrev;
|
||||||
|
// Hold the load guards shut until the scroll is reapplied, so the
|
||||||
|
// InfiniteScroll sentinels can't fire a stray page load at the top.
|
||||||
|
loading = true;
|
||||||
|
pendingScroll = snap.scrollTop;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
files = [];
|
files = [];
|
||||||
nextCursor = null;
|
nextCursor = null;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
@@ -149,9 +186,52 @@
|
|||||||
if (anchor) {
|
if (anchor) {
|
||||||
scrollToFile(anchor);
|
scrollToFile(anchor);
|
||||||
consumeAnchor();
|
consumeAnchor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reapply a cached scroll position after a section-cache rehydrate.
|
||||||
|
if (pendingScroll != null) {
|
||||||
|
restoreScrollTop(pendingScroll);
|
||||||
|
pendingScroll = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Snapshot the loaded grid, cursors and scroll position on the way out, so
|
||||||
|
// returning to this section restores them instead of refetching. Skipped for
|
||||||
|
// the shallow-routed viewer (pushState doesn't trigger a navigation) — only
|
||||||
|
// real departures to another route reach here.
|
||||||
|
beforeNavigate((nav) => {
|
||||||
|
// Staying on the list (a sort/filter query change via goto) isn't a
|
||||||
|
// departure — nothing to snapshot.
|
||||||
|
if (nav.to?.url.pathname === '/files') return;
|
||||||
|
if (files.length === 0) return;
|
||||||
|
const scroller = getScroller();
|
||||||
|
saveSection<FilesSnapshot>('files', scroller.scrollTop, {
|
||||||
|
resetKey,
|
||||||
|
files,
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
prevCursor,
|
||||||
|
hasPrev
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reapply a restored scroll offset, retrying across frames because the grid
|
||||||
|
// may not be laid out yet right after rehydrate. Releases the load guard once
|
||||||
|
// applied so InfiniteScroll can resume.
|
||||||
|
function restoreScrollTop(top: number) {
|
||||||
|
let tries = 10;
|
||||||
|
const apply = () => {
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (scroller.scrollHeight > top + scroller.clientHeight || tries-- <= 0) {
|
||||||
|
scroller.scrollTop = top;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(apply);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(apply);
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll the grid so the given file is centred. Uses scrollIntoView (works
|
// Scroll the grid so the given file is centred. Uses scrollIntoView (works
|
||||||
// whether the actual scroller is <main> or the window) and retries across
|
// whether the actual scroller is <main> or the window) and retries across
|
||||||
// frames because the cards may not be laid out yet right after a restore.
|
// frames because the cards may not be laid out yet right after a restore.
|
||||||
|
|||||||
Reference in New Issue
Block a user