From afbc0ba5155bdafaaf44b6b7f63c907ba62f9b23 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 16:12:20 +0000 Subject: [PATCH] perf(files): resolve breadcrumbs from a name cache, not N getFolder calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every navigation rebuilt the breadcrumb with one `GET /api/folders/{id}` per path segment (a depth-D folder = D requests, each no-store) purely to label the trail. Add an id→name cache, populated wherever a name is already known: - every listing names its children, so `cacheFolder` records them, and - `getFolder` records the folder it fetched. `buildCrumbs` now reads names from the cache and only fetches the ids it hasn't seen. During normal step-by-step navigation each ancestor was named by its parent's listing, so the breadcrumb resolves with ZERO extra requests; only a cold deep-link fetches its unknown ancestors (still in parallel). Folder renames update the cache immediately so the trail stays correct. The cache is a small LRU (cap 1000 — names are tiny) and is independent of the listing cache (names survive a listing invalidation). Validated: 3 new unit tests (listing populates child names, getFolder records, rename overwrites) → 46 frontend tests green; npm run check; headless render of the real files route (list + grid) — no errors. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- .../src/lib/api/endpoints/folders.test.ts | 36 ++++++++++++++++++- frontend/src/lib/api/endpoints/folders.ts | 30 ++++++++++++++-- .../src/routes/files/[...path]/+page.svelte | 29 ++++++++++----- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/api/endpoints/folders.test.ts b/frontend/src/lib/api/endpoints/folders.test.ts index 703ed19f..68d61943 100644 --- a/frontend/src/lib/api/endpoints/folders.test.ts +++ b/frontend/src/lib/api/endpoints/folders.test.ts @@ -2,12 +2,16 @@ 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 { apiFetch, apiJson } from '$lib/api/client'; +import type { FolderItem } from '$lib/api/types'; import { fetchFolderListing, getCachedFolder, cacheFolder, invalidateFolderCache, + getFolder, + getFolderName, + rememberFolderName, type FolderListing } from './folders'; @@ -106,3 +110,33 @@ describe('folder listing cache (LRU + invalidation)', () => { expect(getCachedFolder('b')).toBeUndefined(); }); }); + +describe('folder name cache (breadcrumbs)', () => { + const folder = (id: string, name: string): FolderItem => ({ id, name }) as unknown as FolderItem; + + it("learns its children's names from a cached listing", () => { + cacheFolder('nc-parent', { + folders: [folder('nc-a', 'Alpha'), folder('nc-b', 'Beta')], + files: [], + favoriteIds: [], + sharedIds: [] + }); + expect(getFolderName('nc-a')).toBe('Alpha'); + expect(getFolderName('nc-b')).toBe('Beta'); + expect(getFolderName('nc-unknown')).toBeUndefined(); + }); + + it('records the name fetched by getFolder', async () => { + vi.mocked(apiJson).mockResolvedValue(folder('gf-1', 'Reports') as never); + const f = await getFolder('gf-1'); + expect(f.name).toBe('Reports'); + expect(getFolderName('gf-1')).toBe('Reports'); + }); + + it('rememberFolderName overwrites a stale name (e.g. after a rename)', () => { + rememberFolderName('rn-1', 'Old'); + expect(getFolderName('rn-1')).toBe('Old'); + rememberFolderName('rn-1', 'New'); + expect(getFolderName('rn-1')).toBe('New'); + }); +}); diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index ae9feefa..0aa00b8b 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -48,6 +48,8 @@ export function getCachedFolder(folderId: string): CachedFolder | undefined { } export function cacheFolder(folderId: string, listing: FolderListing, etag?: string): void { + // Learn the children's names for breadcrumb resolution. + for (const f of listing.folders) rememberFolderName(f.id, f.name); folderCache.delete(folderId); folderCache.set(folderId, { listing, etag }); // Evict the least-recently-used entries past the cap. @@ -64,6 +66,28 @@ export function invalidateFolderCache(folderId?: string): void { else folderCache.delete(folderId); } +// ── Folder name cache (breadcrumbs) ────────────────────────────────────────── +// id → name, learned from every listing (a folder's listing names its children) +// and from getFolder. Lets breadcrumbs resolve with zero requests during normal +// navigation (each ancestor was named by its parent's listing); only a cold +// deep-link fetches the names it hasn't seen. +const FOLDER_NAMES_MAX = 1000; +const folderNames = new Map(); + +export function rememberFolderName(id: string, name: string): void { + folderNames.delete(id); + folderNames.set(id, name); + while (folderNames.size > FOLDER_NAMES_MAX) { + const oldest = folderNames.keys().next().value; + if (oldest === undefined) break; + folderNames.delete(oldest); + } +} + +export function getFolderName(id: string): string | undefined { + return folderNames.get(id); +} + function parseListing(raw: unknown): FolderListing { const o = (raw ?? {}) as { folders?: FolderItem[]; @@ -84,8 +108,10 @@ export function listRootFolders(): Promise { return apiJson('/api/folders', { credentials: 'same-origin' }); } -export function getFolder(id: string): Promise { - return apiJson(`/api/folders/${id}`, NO_CACHE); +export async function getFolder(id: string): Promise { + const folder = await apiJson(`/api/folders/${id}`, NO_CACHE); + rememberFolderName(folder.id, folder.name); + return folder; } /** diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 0f9e9c0a..7c46f511 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -12,8 +12,10 @@ fetchFolderListing, getCachedFolder, getFolder, + getFolderName, invalidateFolderCache, moveFolder, + rememberFolderName, renameFolder, type FolderListing } from '$lib/api/endpoints/folders'; @@ -125,15 +127,21 @@ } async function buildCrumbs(segments: string[]): Promise> { - // Names for each id in the trail; tolerate failures with a fallback label. - const metas = await Promise.all( - segments.map((id) => - getFolder(id) - .then((f) => ({ id, name: f.name })) - .catch(() => ({ id, name: '…' })) - ) + // Names come from the cache first (every listing names its children, so + // step-by-step navigation needs zero requests); only ids we've never seen + // — a cold deep-link's ancestors — are fetched, in parallel. + return Promise.all( + segments.map(async (id) => { + const known = getFolderName(id); + if (known !== undefined) return { id, name: known }; + try { + const f = await getFolder(id); + return { id, name: f.name }; + } catch { + return { id, name: '…' }; + } + }) ); - return metas; } // Bumped on every load; a stale in-flight response checks this before it @@ -329,7 +337,10 @@ if (!name || name === current) return; try { if (kind === 'file') await renameFile(id, name); - else await renameFolder(id, name); + else { + await renameFolder(id, name); + rememberFolderName(id, name); // keep breadcrumbs current immediately + } await reload(); } catch (e) { errorToast(e);