From 18f1dbc052a9a787693e3327136e4309bbc792ee Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Wed, 10 Jun 2026 16:30:26 +0300 Subject: [PATCH] fix(frontend): restore grid position via URL anchor on return from viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
, being the effective scroller, so scrollTop restoration was inert. - The viewer's back/Escape now return to /files?anchor= 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
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 --- frontend/src/lib/stores/filesCache.ts | 56 ++++++++++-- frontend/src/routes/files/+page.svelte | 95 ++++++++++++++++++--- frontend/src/routes/files/[id]/+page.svelte | 16 +++- 3 files changed, 146 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/stores/filesCache.ts b/frontend/src/lib/stores/filesCache.ts index 338e11b..18fe6d5 100644 --- a/frontend/src/lib/stores/filesCache.ts +++ b/frontend/src/lib/stores/filesCache.ts @@ -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 { + hydrate(); if (!snapshot || !snapshot.hasMore || loading) return; loading = true; try { @@ -77,6 +122,7 @@ export async function loadMoreIntoSnapshot(limit: number): Promise { nextCursor: res.next_cursor ?? null, hasMore: !!res.next_cursor, }; + persist(); } catch { // Non-critical: leave the snapshot unchanged. } finally { diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 8ff2bde..6b013ef 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -1,6 +1,6 @@