perf(frontend): load favorite/share badges once per session, not per navigation

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
Claude
2026-06-19 15:00:14 +00:00
parent 8794324c3d
commit 546dcef305
4 changed files with 176 additions and 46 deletions
@@ -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<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite + shared badges for items in the current folder.
let favoriteIds = $state<Set<string>>(new Set());
let sharedIds = $state<Set<string>>(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 @@
<div class="name-cell">
<div class="file-icon"><Icon name="folder" /></div>
<span title={folder.name}>{folder.name}</span>
{#if favoriteIds.has(folder.id)}<div
{#if badges.isFavorite(folder.id)}<div
class="item-badge item-badge--fav"
title={t('files.favorited', 'Favorite')}
>
<Icon name="star" />
</div>{/if}
{#if sharedIds.has(folder.id)}<div
{#if badges.isShared(folder.id)}<div
class="file-badge file-badge-shared"
title={t('files.shared', 'Shared')}
>
@@ -1241,15 +1219,15 @@
<div class="action-cell">
<button
class="favorite-star"
class:active={favoriteIds.has(folder.id)}
title={favoriteIds.has(folder.id)
class:active={badges.isFavorite(folder.id)}
title={badges.isFavorite(folder.id)
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={favoriteIds.has(folder.id)}
aria-pressed={badges.isFavorite(folder.id)}
onclick={(e) => {
e.stopPropagation();
void toggleFavorite('folder', folder.id);
}}><Icon name={favoriteIds.has(folder.id) ? 'star' : 'star-outline'} /></button
}}><Icon name={badges.isFavorite(folder.id) ? 'star' : 'star-outline'} /></button
>
<button
class="btn-action"
@@ -1336,13 +1314,13 @@
{/if}
</div>
<span title={file.name}>{file.name}</span>
{#if favoriteIds.has(file.id)}<div
{#if badges.isFavorite(file.id)}<div
class="item-badge item-badge--fav"
title={t('files.favorited', 'Favorite')}
>
<Icon name="star" />
</div>{/if}
{#if sharedIds.has(file.id)}<div
{#if badges.isShared(file.id)}<div
class="file-badge file-badge-shared"
title={t('files.shared', 'Shared')}
>
@@ -1360,15 +1338,15 @@
<div class="action-cell">
<button
class="favorite-star"
class:active={favoriteIds.has(file.id)}
title={favoriteIds.has(file.id)
class:active={badges.isFavorite(file.id)}
title={badges.isFavorite(file.id)
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={favoriteIds.has(file.id)}
aria-pressed={badges.isFavorite(file.id)}
onclick={(e) => {
e.stopPropagation();
void toggleFavorite('file', file.id);
}}><Icon name={favoriteIds.has(file.id) ? 'star' : 'star-outline'} /></button
}}><Icon name={badges.isFavorite(file.id) ? 'star' : 'star-outline'} /></button
>
<button
class="btn-action"
@@ -1431,7 +1409,7 @@
void load();
}}
/>
<ShareDialog bind:open={shareOpen} item={actionTarget} />
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => badges.markShared(id)} />
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<WopiEditor
bind:open={wopiOpen}
@@ -1562,7 +1540,7 @@
}}
>
<Icon name="star" />
{favoriteIds.has(ctxTarget.id)
{badges.isFavorite(ctxTarget.id)
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
</button>