perf(files): resolve breadcrumbs from a name cache, not N getFolder calls
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
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<FolderItem[]> {
|
||||
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export function getFolder(id: string): Promise<FolderItem> {
|
||||
return apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
export async function getFolder(id: string): Promise<FolderItem> {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Array<{ id: string; name: string }>> {
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user