From 546dcef305acf63f570693870d36fb2c33cc38e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:00:14 +0000 Subject: [PATCH] perf(frontend): load favorite/share badges once per session, not per navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The files browser re-fetched the first 200 favorites AND the first 200 outgoing shares on every folder navigation (two round-trips each time) just to render the star / shared badges — work that grew with how much the user browsed, for data that barely changes. Move both id sets into a session-scoped `badges` store: `ensureLoaded()` fetches once (concurrent callers share one in-flight request) and every later navigation reads from cache, so browsing costs zero extra requests. Mutations keep the cache in sync optimistically: - favorite toggle / batch-favorite → `setFavorite` (revert on failure), - share creation → `markShared`, wired through a new optional `onshared` callback on ShareDialog (fired when a grant or public link is created). This also makes the shared badge appear immediately instead of only after re-navigating. Net effect per session: badge fetches drop from O(navigations) × 2 to 2 total. The 200-item ceiling is unchanged from before; the fully-correct fix is per-item flags on the listing endpoint (a backend change, noted in the store). Verified: new badges store unit tests (load-once, concurrent de-dupe, optimistic favorite/share, reset) and a headless render of the real files route in list and grid (virtualization intact, no runtime errors). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- .../src/lib/components/ShareDialog.svelte | 6 +- frontend/src/lib/stores/badges.svelte.ts | 73 ++++++++++++++++++ frontend/src/lib/stores/badges.test.ts | 75 +++++++++++++++++++ .../src/routes/files/[...path]/+page.svelte | 68 ++++++----------- 4 files changed, 176 insertions(+), 46 deletions(-) create mode 100644 frontend/src/lib/stores/badges.svelte.ts create mode 100644 frontend/src/lib/stores/badges.test.ts diff --git a/frontend/src/lib/components/ShareDialog.svelte b/frontend/src/lib/components/ShareDialog.svelte index 8dbd6403..29f0122a 100644 --- a/frontend/src/lib/components/ShareDialog.svelte +++ b/frontend/src/lib/components/ShareDialog.svelte @@ -43,9 +43,11 @@ interface Props { open: boolean; item: Target | null; + /** Fired with the item id when an outgoing share (grant or link) is created. */ + onshared?: (id: string) => void; } - let { open = $bindable(false), item }: Props = $props(); + let { open = $bindable(false), item, onshared }: Props = $props(); let tab = $state<'people' | 'link'>('people'); let directoryAvailable = $state(true); @@ -159,6 +161,7 @@ query = ''; results = []; summarizeNotifications(res.notification.outcomes); + onshared?.(item.id); await loadGrants(); } catch (e) { errorToast(e); @@ -280,6 +283,7 @@ newLinkName = ''; password = ''; expiresAt = null; + onshared?.(item.id); await loadShares(); ui.notify(t('share.created', 'Public link created'), 'success'); } catch (e) { diff --git a/frontend/src/lib/stores/badges.svelte.ts b/frontend/src/lib/stores/badges.svelte.ts new file mode 100644 index 00000000..8b081ee0 --- /dev/null +++ b/frontend/src/lib/stores/badges.svelte.ts @@ -0,0 +1,73 @@ +/** + * Session-scoped favorite / outgoing-share badge sets. + * + * The files browser shows a star (favorite) and a link (shared) badge per row. + * Previously every folder navigation re-fetched the first 200 favorites AND the + * first 200 shares — two round-trips per navigation, for data that barely + * changes. This caches both id sets once per session (`ensureLoaded`, deduped) + * and keeps them in sync via optimistic mutations from the views that toggle + * them, so navigating folders costs zero extra requests. + * + * (The 200-item ceiling is inherited from the previous implementation; the truly + * complete fix is to have the listing endpoint return per-item flags, a backend + * change tracked separately.) + */ +import { fetchFavoritesPage } from '$lib/api/endpoints/favorites'; +import { fetchMyShares } from '$lib/api/endpoints/grants'; + +class BadgesStore { + #favorites = $state>(new Set()); + #shared = $state>(new Set()); + #loaded = false; + #inflight: Promise | null = null; + + isFavorite(id: string): boolean { + return this.#favorites.has(id); + } + + isShared(id: string): boolean { + return this.#shared.has(id); + } + + /** Load both id sets once per session. Concurrent callers share one fetch. */ + ensureLoaded(): Promise { + if (this.#loaded) return Promise.resolve(); + if (this.#inflight) return this.#inflight; + this.#inflight = (async () => { + const [favs, shares] = await Promise.all([ + fetchFavoritesPage({ limit: 200 }).catch(() => null), + fetchMyShares({ limit: 200 }).catch(() => null) + ]); + if (favs) this.#favorites = new Set(favs.items.map((f) => f.resource.id)); + if (shares) this.#shared = new Set(shares.items.map((s) => s.resource.id)); + this.#loaded = true; + this.#inflight = null; + })(); + return this.#inflight; + } + + /** Optimistically reflect a favorite toggle (no refetch). */ + setFavorite(id: string, on: boolean): void { + if (on === this.#favorites.has(id)) return; + const next = new Set(this.#favorites); + if (on) next.add(id); + else next.delete(id); + this.#favorites = next; + } + + /** Mark an item as having an outgoing share (after one is created). */ + markShared(id: string): void { + if (this.#shared.has(id)) return; + this.#shared = new Set(this.#shared).add(id); + } + + /** Drop the cache (e.g. on logout) so the next session reloads fresh. */ + reset(): void { + this.#favorites = new Set(); + this.#shared = new Set(); + this.#loaded = false; + this.#inflight = null; + } +} + +export const badges = new BadgesStore(); diff --git a/frontend/src/lib/stores/badges.test.ts b/frontend/src/lib/stores/badges.test.ts new file mode 100644 index 00000000..0bfc3f26 --- /dev/null +++ b/frontend/src/lib/stores/badges.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api/endpoints/favorites', () => ({ fetchFavoritesPage: vi.fn() })); +vi.mock('$lib/api/endpoints/grants', () => ({ fetchMyShares: vi.fn() })); + +import { fetchFavoritesPage } from '$lib/api/endpoints/favorites'; +import { fetchMyShares } from '$lib/api/endpoints/grants'; +import { badges } from './badges.svelte'; + +const favPage = (...ids: string[]) => + ({ items: ids.map((id) => ({ resource: { id } })) }) as unknown as Awaited< + ReturnType + >; +const sharePage = (...ids: string[]) => + ({ items: ids.map((id) => ({ resource: { id } })) }) as unknown as Awaited< + ReturnType + >; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchFavoritesPage).mockResolvedValue(favPage('f1', 'f2')); + vi.mocked(fetchMyShares).mockResolvedValue(sharePage('s1')); + badges.reset(); +}); + +describe('badges store', () => { + it('loads once and serves every later navigation from cache', async () => { + // Five "folder navigations" each call ensureLoaded. + for (let i = 0; i < 5; i++) await badges.ensureLoaded(); + + expect(fetchFavoritesPage).toHaveBeenCalledTimes(1); + expect(fetchMyShares).toHaveBeenCalledTimes(1); + expect(badges.isFavorite('f1')).toBe(true); + expect(badges.isFavorite('f2')).toBe(true); + expect(badges.isShared('s1')).toBe(true); + expect(badges.isFavorite('nope')).toBe(false); + }); + + it('collapses concurrent loads into a single fetch', async () => { + await Promise.all([ + badges.ensureLoaded(), + badges.ensureLoaded(), + badges.ensureLoaded(), + badges.ensureLoaded() + ]); + expect(fetchFavoritesPage).toHaveBeenCalledTimes(1); + expect(fetchMyShares).toHaveBeenCalledTimes(1); + }); + + it('reflects favorite toggles optimistically without refetching', async () => { + await badges.ensureLoaded(); + badges.setFavorite('x', true); + expect(badges.isFavorite('x')).toBe(true); + badges.setFavorite('x', false); + expect(badges.isFavorite('x')).toBe(false); + // No extra network for optimistic updates. + expect(fetchFavoritesPage).toHaveBeenCalledTimes(1); + }); + + it('marks an item shared after a share is created', async () => { + await badges.ensureLoaded(); + expect(badges.isShared('new')).toBe(false); + badges.markShared('new'); + expect(badges.isShared('new')).toBe(true); + }); + + it('reset() clears the cache and allows a fresh reload', async () => { + await badges.ensureLoaded(); + expect(fetchFavoritesPage).toHaveBeenCalledTimes(1); + badges.reset(); + expect(badges.isFavorite('f1')).toBe(false); + await badges.ensureLoaded(); + expect(fetchFavoritesPage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 184459be..3290fd01 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -25,8 +25,7 @@ } from '$lib/api/endpoints/files'; import { folderZipUrl } from '$lib/api/endpoints/folders'; import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload'; - import { addFavorite, fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites'; - import { fetchMyShares } from '$lib/api/endpoints/grants'; + import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites'; import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi'; import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music'; import { apiFetch } from '$lib/api/client'; @@ -40,6 +39,7 @@ import WopiEditor from '$lib/components/WopiEditor.svelte'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; + import { badges } from '$lib/stores/badges.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -80,10 +80,6 @@ let actionTarget = $state(null); let moveItems = $state(null); - // Favorite + shared badges for items in the current folder. - let favoriteIds = $state>(new Set()); - let sharedIds = $state>(new Set()); - function openMove(kind: ItemType, id: string, name: string) { actionTarget = { id, name, kind }; moveItems = null; @@ -101,33 +97,16 @@ shareOpen = true; } - /** Load favorite + outgoing-share id sets so items can show badges. */ - async function loadBadges() { - try { - const [favs, shares] = await Promise.all([ - fetchFavoritesPage({ limit: 200 }).catch(() => null), - fetchMyShares({ limit: 200 }).catch(() => null) - ]); - favoriteIds = new Set((favs?.items ?? []).map((f) => f.resource.id)); - sharedIds = new Set((shares?.items ?? []).map((s) => s.resource.id)); - } catch { - /* badges are best-effort */ - } - } - async function toggleFavorite(kind: ItemType, id: string) { - const isFav = favoriteIds.has(id); - // Optimistic toggle, reconcile on failure. - const next = new Set(favoriteIds); - if (isFav) next.delete(id); - else next.add(id); - favoriteIds = next; + const isFav = badges.isFavorite(id); + // Optimistic toggle, reverted on failure. + badges.setFavorite(id, !isFav); try { if (isFav) await removeFavorite(kind, id); else await addFavorite(kind, id); } catch (e) { errorToast(e); - await loadBadges(); + badges.setFavorite(id, isFav); } } @@ -168,7 +147,7 @@ const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]); listing = data; crumbs = trail; - void loadBadges(); + void badges.ensureLoaded(); maybeOpenDeepLink(); } catch (e) { // 403 → friendly message rather than the raw "Forbidden" error string. @@ -444,7 +423,7 @@ /** Batch add the selection to favorites — single /api/favorites/batch call. */ async function batchFavorites() { - const items = selectionTargets().filter((it) => !favoriteIds.has(it.id)); + const items = selectionTargets().filter((it) => !badges.isFavorite(it.id)); if (items.length === 0) { ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info'); clearSelection(); @@ -460,10 +439,9 @@ }) }); if (!res.ok) throw new Error(`Server returned ${res.status}`); - favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]); + for (const it of items) badges.setFavorite(it.id, true); ui.notify(t('files.added_favorites', 'Added to favorites'), 'success'); clearSelection(); - void loadBadges(); } catch (e) { errorToast(e); } @@ -1218,13 +1196,13 @@
{folder.name} - {#if favoriteIds.has(folder.id)}
{/if} - {#if sharedIds.has(folder.id)}
@@ -1241,15 +1219,15 @@