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 {