fix(frontend): restore grid position via URL anchor on return from viewer

Returning from the file viewer left the grid scrolled to the top: the
position lived only in volatile module state and was never carried
anywhere, and the scroll restore ran before SvelteKit's own scroll reset
(on goto) clobbered it back to the top — worsened by the body, not
<main>, being the effective scroller, so scrollTop restoration was inert.

- The viewer's back/Escape now return to /files?anchor=<currentId> with
  noScroll, carrying the position in the URL (survives reload, no longer
  depends on hidden in-memory state).
- The list restores grid DATA from the snapshot as before, but scrolls in
  afterNavigate — which runs AFTER SvelteKit's scroll handling — using
  scrollIntoView so it works whether <main> or the window scrolls. The
  ?anchor is consumed (stripped via shallow replaceState) once applied.
- Deep link / hard reload with an anchor but no cached grid falls back to
  loading a page anchored at that file, then scrolling to it.
- Snapshot is mirrored to sessionStorage so a refresh still restores.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:30:26 +03:00
parent a1ec25a441
commit 18f1dbc052
3 changed files with 146 additions and 21 deletions
+51 -5
View File
@@ -1,3 +1,4 @@
import { browser } from '$app/environment';
import { api } from '$lib/api/client';
import type { File, FileCursorPage } from '$lib/api/types';
@@ -9,13 +10,20 @@ export interface FilesQuery {
}
/**
* A snapshot of the files grid, kept in memory so that opening a file and
* returning restores the same list (and scroll position) instead of reloading
* page 1 from the top. The file viewer also reads this to derive prev/next and
* extends it as the user pages past the loaded set.
* A snapshot of the files grid, kept so that opening a file and returning
* restores the same list (and scroll position) instead of reloading page 1 from
* the top. The file viewer also reads this to derive prev/next, to find the list
* URL to return to, and extends it as the user pages past the loaded set.
*
* Held in a module variable (survives client-side navigation) AND mirrored to
* sessionStorage (survives a full reload / deep navigation within the tab).
*/
export interface FilesSnapshot {
query: FilesQuery;
/** Search string of the list URL this grid was viewed at (e.g. "?filter=x"),
* so the viewer returns to the exact same filtered list rather than bare
* /files — otherwise the filter is lost and the snapshot no longer matches. */
listSearch: string;
files: File[];
nextCursor: string | null;
hasMore: boolean;
@@ -30,27 +38,63 @@ export function queryKey(q: FilesQuery): string {
return `${q.sort}|${q.order}|${q.filter ?? ''}`;
}
const STORAGE_KEY = 'filesSnapshot';
let snapshot: FilesSnapshot | null = null;
let hydrated = false;
let loading = false;
/** Lazily restore the snapshot from sessionStorage the first time it's read so
* the position survives a page reload, not just client-side navigation. */
function hydrate(): void {
if (hydrated) return;
hydrated = true;
if (!browser) return;
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) snapshot = JSON.parse(raw) as FilesSnapshot;
} catch {
// Corrupt/missing — start fresh.
}
}
function persist(): void {
if (!browser) return;
try {
if (snapshot) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
else sessionStorage.removeItem(STORAGE_KEY);
} catch {
// Quota or serialization failure — non-critical, in-memory copy still works.
}
}
/** Save (replace) the current grid snapshot. */
export function saveFilesSnapshot(s: FilesSnapshot): void {
snapshot = s;
hydrated = true;
persist();
}
/** Read the snapshot without consuming it. */
export function peekFilesSnapshot(): FilesSnapshot | null {
hydrate();
return snapshot;
}
/** Forget the snapshot (e.g. on logout). */
export function clearFilesSnapshot(): void {
snapshot = null;
hydrated = true;
persist();
}
/** Record the file currently being viewed so back-navigation lands on it. */
export function setLastOpened(id: string): void {
if (snapshot) snapshot = { ...snapshot, lastOpenedId: id };
hydrate();
if (snapshot) {
snapshot = { ...snapshot, lastOpenedId: id };
persist();
}
}
/**
@@ -61,6 +105,7 @@ export function setLastOpened(id: string): void {
* a load is already in flight.
*/
export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
hydrate();
if (!snapshot || !snapshot.hasMore || loading) return;
loading = true;
try {
@@ -77,6 +122,7 @@ export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
nextCursor: res.next_cursor ?? null,
hasMore: !!res.next_cursor,
};
persist();
} catch {
// Non-critical: leave the snapshot unchanged.
} finally {
+81 -14
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { afterNavigate, goto, replaceState } from '$app/navigation';
import { api } from '$lib/api/client';
import { ApiError } from '$lib/api/client';
import FileCard from '$lib/components/file/FileCard.svelte';
@@ -21,7 +21,6 @@
saveFilesSnapshot,
peekFilesSnapshot,
queryKey,
type FilesSnapshot,
} from '$lib/stores/filesCache';
let scrollContainer = $state<HTMLElement | undefined>();
@@ -92,48 +91,113 @@
let filterOpen = $state(false);
let filterParam = $derived(page.url.searchParams.get('filter'));
let anchorParam = $derived(page.url.searchParams.get('anchor'));
let activeTokens = $derived(parseDslFilter(filterParam));
let sortState = $derived($fileSorting);
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
let prevKey = $state('');
// Restore the grid DATA on entry. Scroll restoration is handled separately in
// afterNavigate (below), which runs after SvelteKit's own scroll reset.
$effect(() => {
const key = resetKey;
if (key === prevKey) return;
const firstRun = prevKey === '';
prevKey = key;
// On the first mount, restore the grid the user left when opening a file
// (same sort/order/filter) so back-navigation keeps their place. Any later
// change means the query itself changed → reset and reload from the top.
// On entry, restore the grid the user left when opening a file (same
// sort/order/filter) so back-navigation keeps their place. A later change
// means the query itself changed → reset and reload from the top.
const snap = peekFilesSnapshot();
if (firstRun && snap && queryKey(snap.query) === key) {
files = snap.files;
nextCursor = snap.nextCursor;
hasMore = snap.hasMore;
void tick().then(() => restoreScroll(snap));
} else {
files = [];
nextCursor = null;
hasMore = true;
error = '';
// Deep link / reload carrying a position anchor but no cached grid:
// load a window starting at the anchor so we have something to scroll to.
if (firstRun && anchorParam) {
void loadAroundAnchor(anchorParam);
}
}
});
// Scroll the grid so the last-opened file is centred; fall back to the saved
// scroll offset if that card isn't present (e.g. nothing was opened).
function restoreScroll(snap: FilesSnapshot) {
if (!scrollContainer) return;
const idx = snap.lastOpenedId ? files.findIndex((f) => f.id === snap.lastOpenedId) : -1;
if (idx >= 0) {
const card = scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`);
// Scroll restoration runs here because afterNavigate fires AFTER SvelteKit has
// applied its own scroll handling, so our position wins instead of being reset
// to the top. The anchor (last-viewed file) is read from the URL.
afterNavigate((nav) => {
const anchor = page.url.searchParams.get('anchor');
if (anchor) {
scrollToFile(anchor);
consumeAnchor();
return;
}
// Plain entry/reload (no explicit anchor): fall back to the snapshot's
// last-opened file so a refresh still lands near where the user was.
if (nav.type === 'enter') {
scrollToFile(peekFilesSnapshot()?.lastOpenedId ?? null);
}
});
// Scroll the grid so the given file is centred. Uses scrollIntoView (works
// 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.
function scrollToFile(anchorId: string | null) {
if (!anchorId) return;
const attempt = (tries: number) => {
const idx = files.findIndex((f) => f.id === anchorId);
const card =
idx >= 0 && scrollContainer
? scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`)
: null;
if (card) {
card.scrollIntoView({ block: 'center' });
return;
}
if (tries > 0) requestAnimationFrame(() => attempt(tries - 1));
};
requestAnimationFrame(() => attempt(10));
}
// Drop the ?anchor= param once consumed so it doesn't linger in the URL or
// re-fire on later interactions. Shallow update — no navigation, no scroll.
function consumeAnchor() {
const url = new URL(page.url);
if (!url.searchParams.has('anchor')) return;
url.searchParams.delete('anchor');
replaceState(`${url.pathname}${url.search}`, page.state);
}
// Fallback for a deep link / hard reload that has an anchor but no cached grid:
// fetch a page anchored at that file so we can scroll to it.
async function loadAroundAnchor(anchor: string) {
loading = true;
error = '';
try {
const params = new URLSearchParams({
anchor,
limit: String(LIMIT),
sort: sortState.sort,
order: sortState.order,
});
if (filterParam) params.set('filter', filterParam);
const res = await api.get<FileCursorPage>(`/files?${params}`);
files = res.items ?? [];
nextCursor = res.next_cursor ?? null;
hasMore = !!res.next_cursor;
await tick();
scrollToFile(anchor);
consumeAnchor();
} catch (err) {
error = err instanceof ApiError ? err.message : 'Failed to load files';
} finally {
loading = false;
}
scrollContainer.scrollTop = snap.scrollTop;
}
async function loadMore() {
@@ -183,6 +247,9 @@
// and scroll position instead of reloading page 1 from the top.
saveFilesSnapshot({
query: { sort: sortState.sort, order: sortState.order, filter: filterParam },
// Only the filter — never the transient ?anchor — defines the list URL
// to return to.
listSearch: filterParam ? `?filter=${encodeURIComponent(filterParam)}` : '',
files,
nextCursor,
hasMore,
+14 -2
View File
@@ -219,11 +219,23 @@
goto(`/files/${f.id}`);
}
// Return to the list the user came from, passing the current file as an
// ?anchor=<id> so the grid scrolls back to it (the position is carried in the
// URL — survives reload and doesn't depend on hidden in-memory state).
// noScroll stops SvelteKit from jumping the list to the top first.
function backToList() {
const snap = peekFilesSnapshot();
const params = new URLSearchParams(snap?.listSearch ?? '');
if (fileId) params.set('anchor', fileId);
const qs = params.toString();
goto('/files' + (qs ? `?${qs}` : ''), { noScroll: true });
}
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft') navigateTo(prevFile);
if (e.key === 'ArrowRight') navigateTo(nextFile);
if (e.key === 'Escape') goto('/files');
if (e.key === 'Escape') backToList();
}
// ---- Helpers ----
@@ -252,7 +264,7 @@
<div class="viewer-page">
<!-- Top bar -->
<div class="top-bar">
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
<button class="back-btn" onclick={backToList} aria-label="Back to files">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>