diff --git a/frontend/src/lib/api/endpoints/folders.test.ts b/frontend/src/lib/api/endpoints/folders.test.ts new file mode 100644 index 00000000..703ed19f --- /dev/null +++ b/frontend/src/lib/api/endpoints/folders.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); + +import { apiFetch } from '$lib/api/client'; +import { + fetchFolderListing, + getCachedFolder, + cacheFolder, + invalidateFolderCache, + type FolderListing +} from './folders'; + +type RawListing = { + folders?: unknown[]; + files?: unknown[]; + favorite_ids?: string[]; + shared_ids?: string[]; +}; + +function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response { + return { + status: opts.status, + ok: opts.status >= 200 && opts.status < 300, + json: async () => opts.body ?? {}, + headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) } + } as unknown as Response; +} + +const emptyListing = (): FolderListing => ({ + folders: [], + files: [], + favoriteIds: [], + sharedIds: [] +}); + +const initHeaders = (call: number): Record => + (vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record; + +beforeEach(() => { + vi.clearAllMocks(); + invalidateFolderCache(); +}); + +describe('fetchFolderListing (conditional)', () => { + it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => { + vi.mocked(apiFetch).mockResolvedValue( + fakeRes({ + status: 200, + body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] }, + etag: '"v1"' + }) + ); + const r = await fetchFolderListing('f1'); + expect(r.status).toBe(200); + expect(r.etag).toBe('"v1"'); + expect(r.listing?.favoriteIds).toEqual(['a']); + expect(r.listing?.sharedIds).toEqual(['b']); + expect(initHeaders(0)['If-None-Match']).toBeUndefined(); + // No cache-busting query param — the URL must be stable for revalidation. + expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe('/api/folders/f1/listing'); + }); + + it('sends If-None-Match and surfaces a 304 with no body', async () => { + vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 })); + const r = await fetchFolderListing('f1', { etag: '"v1"' }); + expect(r.status).toBe(304); + expect(r.listing).toBeUndefined(); + expect(initHeaders(0)['If-None-Match']).toBe('"v1"'); + }); + + it('throws a 403 carrying its status', async () => { + vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 403 })); + await expect(fetchFolderListing('f1')).rejects.toMatchObject({ status: 403 }); + }); +}); + +describe('folder listing cache (LRU + invalidation)', () => { + it('stores and retrieves a listing + its ETag', () => { + cacheFolder('a', emptyListing(), '"1"'); + expect(getCachedFolder('a')?.etag).toBe('"1"'); + expect(getCachedFolder('missing')).toBeUndefined(); + }); + + it('evicts the least-recently-used entry past the cap', () => { + for (let i = 0; i < 45; i++) cacheFolder(`f${i}`, emptyListing()); + expect(getCachedFolder('f0')).toBeUndefined(); // evicted (cap is 40) + expect(getCachedFolder('f44')).toBeDefined(); + }); + + it('a read bumps recency so the touched entry survives eviction', () => { + for (let i = 0; i < 40; i++) cacheFolder(`f${i}`, emptyListing()); + getCachedFolder('f0'); // bump f0 to most-recent + cacheFolder('extra', emptyListing()); // forces one eviction + expect(getCachedFolder('f0')).toBeDefined(); + expect(getCachedFolder('f1')).toBeUndefined(); // f1 was now the oldest + }); + + it('invalidates a single folder, or the whole cache', () => { + cacheFolder('a', emptyListing()); + cacheFolder('b', emptyListing()); + invalidateFolderCache('a'); + expect(getCachedFolder('a')).toBeUndefined(); + expect(getCachedFolder('b')).toBeDefined(); + invalidateFolderCache(); + expect(getCachedFolder('b')).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index f2863b01..ae9feefa 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -19,6 +19,66 @@ export interface FolderListing { sharedIds: string[]; } +/** Result of a (possibly conditional) listing fetch. */ +export interface FolderListingResult { + /** 200 with a fresh `listing`, or 304 → the caller should keep its cache. */ + status: number; + listing?: FolderListing; + etag?: string; +} + +// ── In-memory listing cache (stale-while-revalidate) ───────────────────────── +// Lets the files view paint a previously-visited folder instantly on +// back/forward navigation, then revalidate with `If-None-Match` (304 = no body). +interface CachedFolder { + listing: FolderListing; + etag?: string; +} +const FOLDER_CACHE_MAX = 40; +const folderCache = new Map(); + +/** Cached listing for a folder, bumped to most-recently-used. */ +export function getCachedFolder(folderId: string): CachedFolder | undefined { + const hit = folderCache.get(folderId); + if (hit) { + folderCache.delete(folderId); + folderCache.set(folderId, hit); + } + return hit; +} + +export function cacheFolder(folderId: string, listing: FolderListing, etag?: string): void { + folderCache.delete(folderId); + folderCache.set(folderId, { listing, etag }); + // Evict the least-recently-used entries past the cap. + while (folderCache.size > FOLDER_CACHE_MAX) { + const oldest = folderCache.keys().next().value; + if (oldest === undefined) break; + folderCache.delete(oldest); + } +} + +/** Drop one folder, or the whole cache (no id), after a mutation. */ +export function invalidateFolderCache(folderId?: string): void { + if (folderId === undefined) folderCache.clear(); + else folderCache.delete(folderId); +} + +function parseListing(raw: unknown): FolderListing { + const o = (raw ?? {}) as { + folders?: FolderItem[]; + files?: FileItem[]; + favorite_ids?: string[]; + shared_ids?: string[]; + }; + return { + folders: Array.isArray(o.folders) ? o.folders : [], + files: Array.isArray(o.files) ? o.files : [], + favoriteIds: Array.isArray(o.favorite_ids) ? o.favorite_ids : [], + sharedIds: Array.isArray(o.shared_ids) ? o.shared_ids : [] + }; +} + /** Top-level folders for the user; the first entry is the home folder. */ export function listRootFolders(): Promise { return apiJson('/api/folders', { credentials: 'same-origin' }); @@ -28,33 +88,41 @@ export function getFolder(id: string): Promise { return apiJson(`/api/folders/${id}`, NO_CACHE); } -export async function listFolder(folderId: string, forceRefresh = false): Promise { - const ts = Math.floor(Date.now() / 1000); - let url = `/api/folders/${folderId}/listing?t=${ts}`; - const headers: Record = { - 'Cache-Control': 'no-cache, no-store, must-revalidate' - }; - if (forceRefresh) { - url += '&force_refresh=true'; +/** + * Fetch a folder listing, optionally conditionally. With `etag` set it sends + * `If-None-Match`; the server replies 304 (empty body) when nothing changed — + * the ETag covers folders + files + favorite/share badges — so the caller can + * keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of + * the way; revalidation is driven entirely by our own ETag. + */ +export async function fetchFolderListing( + folderId: string, + opts: { etag?: string; forceRefresh?: boolean } = {} +): Promise { + const headers: Record = {}; + if (opts.etag) headers['If-None-Match'] = opts.etag; + let url = `/api/folders/${folderId}/listing`; + if (opts.forceRefresh) { + url += '?force_refresh=true'; headers['X-Force-Refresh'] = 'true'; } const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers }); + if (res.status === 304) return { status: 304 }; if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); if (!res.ok) throw new Error(`listing failed: ${res.status}`); - const listing = (await res.json()) as { - folders?: FolderItem[]; - files?: FileItem[]; - favorite_ids?: string[]; - shared_ids?: string[]; - }; return { - folders: Array.isArray(listing.folders) ? listing.folders : [], - files: Array.isArray(listing.files) ? listing.files : [], - favoriteIds: Array.isArray(listing.favorite_ids) ? listing.favorite_ids : [], - sharedIds: Array.isArray(listing.shared_ids) ? listing.shared_ids : [] + status: 200, + listing: parseListing(await res.json()), + etag: res.headers.get('ETag') ?? undefined }; } +/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */ +export async function listFolder(folderId: string, forceRefresh = false): Promise { + const res = await fetchFolderListing(folderId, { forceRefresh }); + return res.listing ?? { folders: [], files: [], favoriteIds: [], sharedIds: [] }; +} + export async function createFolder(name: string, parentId: string | null): Promise { const res = await apiFetch('/api/folders', { method: 'POST', diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 09842a51..0f9e9c0a 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -6,10 +6,13 @@ import { page } from '$app/state'; import Icon from '$lib/icons/Icon.svelte'; import { + cacheFolder, createFolder, deleteFolder, + fetchFolderListing, + getCachedFolder, getFolder, - listFolder, + invalidateFolderCache, moveFolder, renameFolder, type FolderListing @@ -133,50 +136,91 @@ return metas; } + // Bumped on every load; a stale in-flight response checks this before it + // writes state, so a fast navigation can't be clobbered by an older fetch. + let loadSeq = 0; + + function applyListing(data: FolderListing) { + listing = data; + favoriteIds = new Set(data.favoriteIds); + sharedIds = new Set(data.sharedIds); + } + async function load() { - loading = true; error = null; - // Arm the delayed skeleton; cancel it the moment the load settles so fast - // loads never flash placeholders (mirrors filesView.js' 100ms timer). + const seq = ++loadSeq; + + // External users have no home folder; send them to shared-with-me. + if (session.isExternalUser && pathSegments.length === 0) { + await goto('/shared-with-me', { replaceState: true }); + return; + } + const home = await session.loadHomeFolder(); + const folderId = pathSegments.at(-1) ?? home; + if (!folderId) { + error = t('files.no_home', 'No home folder available.'); + return; + } + currentId = folderId; + filesStore.currentFolder = folderId; + + // Stale-while-revalidate: paint a previously-visited folder instantly, + // then revalidate with If-None-Match (304 = keep what's shown). + const cached = getCachedFolder(folderId); + if (cached) { + applyListing(cached.listing); + loading = false; + showSkeleton = false; + } else { + loading = true; + } + // Delayed skeleton, only when there's nothing cached to show yet. const skeletonTimer = setTimeout(() => { if (loading) showSkeleton = true; }, 100); + + // Breadcrumbs resolve independently so they never block the grid paint. + void buildCrumbs(pathSegments).then((trail) => { + if (seq === loadSeq) crumbs = trail; + }); + try { - // External users have no home folder; send them to shared-with-me. - if (session.isExternalUser && pathSegments.length === 0) { - await goto('/shared-with-me', { replaceState: true }); - return; + const res = await fetchFolderListing(folderId, { etag: cached?.etag }); + if (seq !== loadSeq) return; // superseded by a newer navigation + if (res.status === 200 && res.listing) { + applyListing(res.listing); + cacheFolder(folderId, res.listing, res.etag); } - const home = await session.loadHomeFolder(); - const folderId = pathSegments.at(-1) ?? home; - if (!folderId) { - error = t('files.no_home', 'No home folder available.'); - return; - } - currentId = folderId; - filesStore.currentFolder = folderId; - const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]); - listing = data; - crumbs = trail; - favoriteIds = new Set(data.favoriteIds); - sharedIds = new Set(data.sharedIds); + // 304 → the cached copy already on screen is current. + error = null; maybeOpenDeepLink(); } catch (e) { - // 403 → friendly message rather than the raw "Forbidden" error string. - const status = (e as { status?: number })?.status; - error = - status === 403 - ? t('errors.forbidden', 'Could not load files') - : e instanceof Error - ? e.message - : String(e); + if (seq !== loadSeq) return; + // With a cached view already shown, keep it on a transient failure. + if (!cached) { + const status = (e as { status?: number })?.status; + error = + status === 403 + ? t('errors.forbidden', 'Could not load files') + : e instanceof Error + ? e.message + : String(e); + } } finally { clearTimeout(skeletonTimer); - loading = false; - showSkeleton = false; + if (seq === loadSeq) { + loading = false; + showSkeleton = false; + } } } + /** Data changed — drop cached listings and reload the current folder fresh. */ + async function reload() { + invalidateFolderCache(); + await load(); + } + /** * Deep-link auto-open: when the URL carries `?file=` and that file is in * the freshly loaded listing, open it in the viewer (ported from @@ -207,7 +251,7 @@ if (!name) return; try { await createFolder(name, currentId); - await load(); + await reload(); } catch (e) { errorToast(e); } @@ -253,7 +297,7 @@ ) : t('files.uploaded', 'Upload complete'); ui.finishProgress(nid, done, 'success'); - await load(); + await reload(); } catch (err) { ui.finishProgress(nid, errorMessage(err), 'error'); } finally { @@ -286,7 +330,7 @@ try { if (kind === 'file') await renameFile(id, name); else await renameFolder(id, name); - await load(); + await reload(); } catch (e) { errorToast(e); } @@ -303,7 +347,7 @@ try { if (kind === 'file') await deleteFile(id); else await deleteFolder(id); - await load(); + await reload(); } catch (e) { errorToast(e); } @@ -522,7 +566,7 @@ } } clearSelection(); - await load(); + await reload(); } // ── Drag-to-move ───────────────────────────────────────────────────────── @@ -561,7 +605,7 @@ else await moveFolder(it.id, targetFolderId); } clearSelection(); - await load(); + await reload(); } catch (err) { errorToast(err); } @@ -738,7 +782,7 @@ await uploadFile(dirId, file); } ui.notify(t('files.uploaded', 'Upload complete'), 'success'); - await load(); + await reload(); } catch (err) { errorToast(err); } finally { @@ -1418,7 +1462,7 @@ mode={moveMode} onmoved={() => { clearSelection(); - void load(); + void reload(); }} />