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
@@ -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) {
+73
View File
@@ -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<Set<string>>(new Set());
#shared = $state<Set<string>>(new Set());
#loaded = false;
#inflight: Promise<void> | 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<void> {
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();
+75
View File
@@ -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<typeof fetchFavoritesPage>
>;
const sharePage = (...ids: string[]) =>
({ items: ids.map((id) => ({ resource: { id } })) }) as unknown as Awaited<
ReturnType<typeof fetchMyShares>
>;
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);
});
});