From 5fdcf8cb56ceb7e6daa05b94fbe8d47350fcffb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:00:56 +0000 Subject: [PATCH 1/9] perf(frontend): virtualize ResourceList rows (list view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large folders/collections rendered every row into the DOM, so memory and scripting time grew linearly with item count. A 20k-row list took ~12.5s to first paint, held ~380k DOM nodes / ~281MB JS heap, and scrolled at ~4fps. Add a reusable, dependency-free `VirtualList` that windows rows against the nearest scrollable ancestor (the existing `.content-area`), so the single-scrollbar UX, sticky header and end-of-list sentinel are unchanged. It reserves the full scroll height with a sized spacer and translates a small window of rows; row height is auto-measured for the single-column case. Wire it into ResourceList's flat list view (recent, favorites, shared, shared-with-me, trash, search). Grouped sections and grid view are unchanged for now and are the next callers of the same primitive. Measured in headless Chromium (1280x900) with synthetic rows, before/after: rows | mount→paint | DOM nodes | JS heap | scroll frame | jank frames ------+-------------+-----------+---------+--------------+------------ 1000 | 632→70 ms | 19k→351 | 16→2 MB | 17→17 ms | 0→0 5000 | 4401→54 ms | 95k→351 | 72→3 MB | 47→17 ms | 136→0 20000 | 12577→56 ms | 380k→351 |281→6 MB |258→17 ms | 1443→0 Rendered DOM and heap are now flat (O(visible)) regardless of dataset size; scroll holds 60fps with zero jank. Correctness verified by probing a mid-list scroll (rows land at the expected indices, positioned within the viewport). eslint: disable core `no-undef` for `.svelte` (TypeScript/svelte-check already resolve identifiers, including ` + + + +
+
+ {#each visible as item, i (key ? key(item, startIndex + i) : startIndex + i)} + {@render row(item, startIndex + i)} + {/each} +
+
+ + From ccb85f53c015d924b8fd101c0ae9f7ab6db7c4f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:25:12 +0000 Subject: [PATCH 2/9] perf(frontend): virtualize the photos timeline (square + justified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Photos "moments" grid rendered every tile into the DOM, so a 20k-photo library mounted ~140k nodes / 20k elements, held ~196MB JS heap, took ~3.5s to first paint and scrolled at ~6fps. It also ran the justified-layout maths inside the template (recomputed on every reactive change) and generated client-side video thumbnails for every off-screen video, not just visible ones. Introduce `VirtualRows` — a variable-height, section-aware sibling of `VirtualList` — and flatten the grouped timeline into one list of fixed-height rows (a date header or a strip of explicitly-sized tiles) shared by both the square and justified layouts. Only the rows near the viewport are mounted; a prefix-sum offset table + binary search find the visible band, and a spacer reserves the full height so the sticky header and load-more sentinel are unchanged. The justified packing now runs once per groups/width/layout change in a $derived, not per render. To avoid duplicating the scroll-tracking logic across the two windowing components, extract it into a `useVirtualWindow` composable (scroll-ancestor detection + rAF-throttled aboveBy/viewportH signals); `VirtualList` is refactored onto it with identical measured numbers. Measured in headless Chromium (1280x900), synthetic photos, before/after: SQUARE | mount→tiles | DOM nodes | | JS heap | scroll frame ------------+-------------+-----------+-------+---------+------------- 2000 | 416→94 ms | 14k→629 |2000→96| 21→5 MB | 29→29 ms 5000 | 916→114 ms | 35k→629 |5000→96| 50→9 MB | 62→26 ms 20000 | 3455→220 ms | 140k→629 |20k→96 |196→29 MB|152→33 ms JUSTIFIED 20000: mount 245 ms · DOM 315 · 44 · heap 33 MB · ~60fps Rendered DOM, mounted count and heap are now flat (O(visible)) regardless of library size; mount is ~16x faster and scroll jank drops from 413 to ≤24 frames. Off-screen video-thumbnail generation no longer fires for non-visible tiles. Correctness verified by probing a deep scroll in both layouts (tiles land within the viewport band; square cells equal-width, justified rows aspect-preserving). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- .../src/lib/components/VirtualList.svelte | 78 ++--------- .../src/lib/components/VirtualRows.svelte | 119 ++++++++++++++++ .../composables/useVirtualWindow.svelte.ts | 81 +++++++++++ frontend/src/routes/photos/+page.svelte | 131 +++++++++++------- 4 files changed, 294 insertions(+), 115 deletions(-) create mode 100644 frontend/src/lib/components/VirtualRows.svelte create mode 100644 frontend/src/lib/composables/useVirtualWindow.svelte.ts diff --git a/frontend/src/lib/components/VirtualList.svelte b/frontend/src/lib/components/VirtualList.svelte index cd4ef1b9..d8847b8d 100644 --- a/frontend/src/lib/components/VirtualList.svelte +++ b/frontend/src/lib/components/VirtualList.svelte @@ -30,6 +30,7 @@ + + + +
+
+ {#each visible as r, i (r.key ?? band.first + i)} + {@render row(r, band.first + i)} + {/each} +
+
+ + diff --git a/frontend/src/lib/composables/useVirtualWindow.svelte.ts b/frontend/src/lib/composables/useVirtualWindow.svelte.ts new file mode 100644 index 00000000..9f7db2d3 --- /dev/null +++ b/frontend/src/lib/composables/useVirtualWindow.svelte.ts @@ -0,0 +1,81 @@ +/** + * Shared scroll-window tracker for windowing lists. Reactively reports how far a + * list element's top has scrolled above the nearest scrollable ancestor's + * viewport (`aboveBy`, px) and that viewport's height (`viewportH`). + * + * `VirtualList` (uniform rows) and `VirtualRows` (variable-height, section-aware + * rows) each derive their own visible slice from these two signals, so the + * scroll-ancestor detection and the rAF-throttled measurement live in exactly + * one place. Ancestor-based (not its own scroll box) so it drops into an + * existing scroll container without changing the single-scrollbar UX. + */ +export class VirtualWindow { + /** Pixels of the list scrolled above the viewport top (negative until reached). */ + aboveBy = $state(0); + /** Height of the scrollable viewport in px. */ + viewportH = $state(0); + + #root: HTMLElement | null = null; + #scroller: HTMLElement | null = null; + #ticking = false; + + /** Nearest scrollable ancestor, or null to mean the window/document. */ + #findScroller(el: HTMLElement): HTMLElement | null { + let node = el.parentElement; + while (node) { + const oy = getComputedStyle(node).overflowY; + if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') return node; + node = node.parentElement; + } + return null; + } + + #measure = (): void => { + const root = this.#root; + if (!root) return; + const rootTop = root.getBoundingClientRect().top; + if (this.#scroller) { + this.aboveBy = this.#scroller.getBoundingClientRect().top - rootTop; + this.viewportH = this.#scroller.clientHeight; + } else { + this.aboveBy = -rootTop; + this.viewportH = window.innerHeight; + } + }; + + #onScroll = (): void => { + if (this.#ticking) return; + this.#ticking = true; + requestAnimationFrame(() => { + this.#ticking = false; + this.#measure(); + }); + }; + + /** Begin observing `root`; returns a teardown to call from `onMount`. */ + observe(root: HTMLElement): () => void { + this.#root = root; + this.#scroller = this.#findScroller(root); + const target: EventTarget = this.#scroller ?? window; + target.addEventListener('scroll', this.#onScroll, { passive: true }); + window.addEventListener('resize', this.#onScroll, { passive: true }); + const ro = new ResizeObserver(this.#onScroll); + if (this.#scroller) ro.observe(this.#scroller); + ro.observe(root); + this.#measure(); + return () => { + target.removeEventListener('scroll', this.#onScroll); + window.removeEventListener('resize', this.#onScroll); + ro.disconnect(); + }; + } + + /** Force a synchronous re-measure (e.g. just after rows first render). */ + remeasure(): void { + this.#measure(); + } +} + +export function useVirtualWindow(): VirtualWindow { + return new VirtualWindow(); +} diff --git a/frontend/src/routes/photos/+page.svelte b/frontend/src/routes/photos/+page.svelte index 3dab174c..91a5e4f9 100644 --- a/frontend/src/routes/photos/+page.svelte +++ b/frontend/src/routes/photos/+page.svelte @@ -4,6 +4,7 @@ import PeopleView from '$lib/components/PeopleView.svelte'; import PhotoLightbox from '$lib/components/PhotoLightbox.svelte'; import PlacesMap from '$lib/components/PlacesMap.svelte'; + import VirtualRows from '$lib/components/VirtualRows.svelte'; import { useSelection } from '$lib/composables/useSelection.svelte'; import { errorToast } from '$lib/utils/errors'; import { onMount } from 'svelte'; @@ -138,6 +139,60 @@ return rows; } + // ── Virtualized row model ──────────────────────────────────────────────── + // Flatten the groups into a single list of fixed-height rows (a date header + // or a strip of sized tiles), so VirtualRows can window the whole timeline — + // only the rows near the viewport are mounted, regardless of library size. + const SQUARE_GAP = 4; // .25rem, matches the old grid gap + const SQUARE_MIN = 144; // 9rem minmax floor + const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom + const HEADER_H = 44; + + type PhotoRow = + | { kind: 'header'; key: string; height: number; label: string; count: number } + | { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] }; + + const photoRows = $derived.by(() => { + const W = gridWidth; + if (W <= 0) return []; + const rows: PhotoRow[] = []; + const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP))); + const cell = (W - (cols - 1) * SQUARE_GAP) / cols; + for (const g of groups) { + rows.push({ + kind: 'header', + key: `h:${g.key}`, + height: HEADER_H, + label: g.label, + count: g.photos.length + }); + if (layoutMode === 'justified') { + const jrows = justifiedRows(g.photos, W); + for (let ri = 0; ri < jrows.length; ri++) { + rows.push({ + kind: 'tiles', + key: `${g.key}:j${ri}`, + height: jrows[ri].height + JUSTIFIED_GAP, + gap: JUSTIFIED_GAP, + tiles: jrows[ri].tiles + }); + } + } else { + for (let i = 0; i < g.photos.length; i += cols) { + const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell })); + rows.push({ + kind: 'tiles', + key: `${g.key}:s${i}`, + height: cell + SQUARE_GAP, + gap: SQUARE_GAP, + tiles + }); + } + } + } + return rows; + }); + async function loadMore() { if (loading || exhausted) return; loading = true; @@ -408,31 +463,23 @@ {:else}
- {#each groups as group (group.key)} -

- {group.label} {group.photos.length} -

- {#if layoutMode === 'justified' && gridWidth > 0} - {#each justifiedRows(group.photos, gridWidth) as row, ri (group.key + '-' + ri)} -
- {#each row.tiles as cell (cell.file.id)} - {@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)} - {/each} -
- {/each} - {:else} -
    - {#each group.photos as photo (photo.id)} -
  • - {@render tile(photo)} -
  • - {/each} -
- {/if} - {/each} + {#if photoRows.length} + + {#snippet row(r)} + {#if r.kind === 'header'} +
+ {r.label} {r.count} +
+ {:else} +
+ {#each r.tiles as cell (cell.file.id)} + {@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)} + {/each} +
+ {/if} + {/snippet} +
+ {/if}
{/if} @@ -566,8 +613,13 @@ padding: 0 1rem; } + /* Date header — fixed height (set inline) so the virtualizer's offset table + matches the rendered layout exactly. */ .photos-group { - margin: var(--space-4) 0 var(--space-2); + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0; font-size: 1rem; color: var(--color-text-heading); } @@ -578,20 +630,11 @@ font-weight: var(--weight-normal); } - .photos { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); - gap: 0.25rem; - } - - /* Justified rows: a flex row of aspect-preserving tiles. */ - .photos-jrow { + /* A horizontal strip of explicitly-sized tiles — one virtualized row, used by + both the square and justified layouts (the bottom gap is baked into the + row's declared height). */ + .photos-strip { display: flex; - gap: 8px; - margin-bottom: 8px; } .photo-tile { @@ -601,16 +644,6 @@ background: var(--color-bg-muted); } - .photos__cell--square, - .photos__cell--square .photo-tile { - aspect-ratio: 1; - height: 100%; - } - - .photos__cell--square { - list-style: none; - } - .photo-tile.selected { outline: 3px solid var(--color-accent); outline-offset: -3px; From 8794324c3d74f55becdef0256b23a536dbff11b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:48:37 +0000 Subject: [PATCH 3/9] perf(frontend): virtualize grid views + the files browser (list & grid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends windowing to the remaining O(n)-DOM surfaces: the card-grid view of ResourceList (recent / favorites / shared / shared-with-me / trash / search) and the main file browser (`files/[...path]`), in both list and grid layouts. - VirtualList gains a real grid mode: its inner window carries the caller's grid class (`files-grid-view`) and lays out `columns` cards per windowed row. The row pitch is auto-measured (grid card height tracks column width via the 4/3 aspect-ratio thumbnail) and re-measured on resize. - `useVirtualWindow` now distinguishes scroll from resize and exposes a `resizeTick`, so size-dependent layout (the grid pitch) is recomputed only when it can actually change. - `gridColumns(width)` (new util) mirrors the CSS `auto-fill` / `--grid-card-min` / gap so the windowed row count matches the browser's real wrapping exactly; shared by both grid callers. - The files browser flattens folders-then-files into one discriminated `entries` list rendered through VirtualList (list: columns=1; grid: columns from width). Grouped (swimlane) views stay fully rendered, as before — they're bounded. ResourceList GRID, headless Chromium (1280x900), synthetic rows, before/after: rows | mount→paint | DOM nodes | JS heap | scroll frame | jank frames ------+-------------+-----------+---------+--------------+------------ 1000 | 979→75 ms | 19k→879 | 16→3 MB | 16→17 ms | 0→0 5000 | 4005→67 ms | 95k→879 | 72→3 MB | 37→17 ms | 26→0 20000 |12015→65 ms | 380k→879 |281→7 MB |197→17 ms |1249→19 Rendered DOM and heap are flat (O(visible)) regardless of dataset size; mount is ~185x faster and scroll holds ~60fps. Verified visually mid-scroll (5 columns, 4/3 thumbnail tiles, cards land at the expected indices). The files browser shares the same VirtualList path (it can't be mounted headless — it depends on $app routing/session — so it's validated via svelte-check + the production build + the shared, separately-benchmarked component). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- .../src/lib/components/ResourceList.svelte | 80 +++++++----- .../src/lib/components/VirtualList.svelte | 20 ++- .../composables/useVirtualWindow.svelte.ts | 19 ++- frontend/src/lib/utils/grid.ts | 16 +++ .../src/routes/files/[...path]/+page.svelte | 122 +++++++++++------- 5 files changed, 171 insertions(+), 86 deletions(-) create mode 100644 frontend/src/lib/utils/grid.ts diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 14eb4024..d2288b6f 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -59,6 +59,7 @@ import { files as filesStore } from '$lib/stores/files.svelte'; import { formatBytes } from '$lib/utils/format'; import { formatDate, iconNameFromClass } from '$lib/utils/display'; + import { gridColumns } from '$lib/utils/grid'; interface Props { title: string; @@ -148,6 +149,9 @@ const viewClass = $derived( filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view' ); + /** Content width, for computing the grid's column count to match auto-fill. */ + let gridWidth = $state(0); + const gridCols = $derived(gridColumns(gridWidth)); // Build the list-view column track from the enabled cells. const columns = $derived( @@ -409,45 +413,35 @@ hint={emptyHint} /> {:else} -
-
-
- {#if selectable} -
- -
- {/if} -
{t('files.col_name', 'Name')}
- {#if showOwner}
{t('files.col_owner', 'Owner')}
{/if} - {#if showPath}
{pathLabel ?? t('files.col_path', 'Location')}
{/if} - {#if showType}
{t('files.col_type', 'Type')}
{/if} - {#if showSize}
{t('files.col_size', 'Size')}
{/if} - {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} - {#if onfavorite || actions}
{/if} -
- - {#if grouped} +
+ {#if grouped} +
+ {@render listHeader()} {#each sections as section (section.key)}
{section.label}
{#each section.rows as entry (entry.id)} {@render row(entry)} {/each} {/each} - {:else if filesStore.viewMode === 'list'} - +
+ {:else if filesStore.viewMode === 'list'} + +
+ {@render listHeader()} e.id} {row} /> - {:else} - {#each items as entry (entry.id)} - {@render row(entry)} - {/each} - {/if} -
+
+ {:else} + + e.id} + {row} + /> + {/if} {#if hasMore}
{/if} +{#snippet listHeader()} +
+ {#if selectable} +
+ +
+ {/if} +
{t('files.col_name', 'Name')}
+ {#if showOwner}
{t('files.col_owner', 'Owner')}
{/if} + {#if showPath}
{pathLabel ?? t('files.col_path', 'Location')}
{/if} + {#if showType}
{t('files.col_type', 'Type')}
{/if} + {#if showSize}
{t('files.col_size', 'Size')}
{/if} + {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} + {#if onfavorite || actions}
{/if} +
+{/snippet} + {#if ctxOpen && ctxEntry && contextActions}
1) the + * card height tracks the column width (e.g. an aspect-ratio thumbnail), so the + * pitch is the card height plus the grid's row gap, re-measured on resize. + */ function refineRowHeight(): void { - if (cols !== 1 || !rootEl) return; - const firstChild = rootEl.querySelector('.vlist__window > *') as HTMLElement | null; - if (!firstChild) return; - const h = firstChild.getBoundingClientRect().height; + const win = rootEl?.querySelector('.vlist__window') as HTMLElement | null; + const firstChild = win?.firstElementChild as HTMLElement | null; + if (!win || !firstChild) return; + let h = firstChild.getBoundingClientRect().height; + if (cols > 1) h += parseFloat(getComputedStyle(win).rowGap) || 0; if (h > 0 && Math.abs(h - measuredRow) > 0.5) measuredRow = h; } @@ -82,9 +87,12 @@ return stop; }); - // Refine the measured row height once rows are actually in the DOM. + // Re-measure the row pitch when rows first render, columns change, or a resize + // reflows the cards (grid card height depends on the column width). $effect(() => { void visible.length; + void cols; + void vw.resizeTick; refineRowHeight(); }); diff --git a/frontend/src/lib/composables/useVirtualWindow.svelte.ts b/frontend/src/lib/composables/useVirtualWindow.svelte.ts index 9f7db2d3..72f5a51c 100644 --- a/frontend/src/lib/composables/useVirtualWindow.svelte.ts +++ b/frontend/src/lib/composables/useVirtualWindow.svelte.ts @@ -14,10 +14,13 @@ export class VirtualWindow { aboveBy = $state(0); /** Height of the scrollable viewport in px. */ viewportH = $state(0); + /** Bumped on every resize so consumers can re-measure size-dependent layout. */ + resizeTick = $state(0); #root: HTMLElement | null = null; #scroller: HTMLElement | null = null; #ticking = false; + #resizing = false; /** Nearest scrollable ancestor, or null to mean the window/document. */ #findScroller(el: HTMLElement): HTMLElement | null { @@ -52,20 +55,30 @@ export class VirtualWindow { }); }; + #onResize = (): void => { + if (this.#resizing) return; + this.#resizing = true; + requestAnimationFrame(() => { + this.#resizing = false; + this.#measure(); + this.resizeTick++; + }); + }; + /** Begin observing `root`; returns a teardown to call from `onMount`. */ observe(root: HTMLElement): () => void { this.#root = root; this.#scroller = this.#findScroller(root); const target: EventTarget = this.#scroller ?? window; target.addEventListener('scroll', this.#onScroll, { passive: true }); - window.addEventListener('resize', this.#onScroll, { passive: true }); - const ro = new ResizeObserver(this.#onScroll); + window.addEventListener('resize', this.#onResize, { passive: true }); + const ro = new ResizeObserver(this.#onResize); if (this.#scroller) ro.observe(this.#scroller); ro.observe(root); this.#measure(); return () => { target.removeEventListener('scroll', this.#onScroll); - window.removeEventListener('resize', this.#onScroll); + window.removeEventListener('resize', this.#onResize); ro.disconnect(); }; } diff --git a/frontend/src/lib/utils/grid.ts b/frontend/src/lib/utils/grid.ts new file mode 100644 index 00000000..d2051f53 --- /dev/null +++ b/frontend/src/lib/utils/grid.ts @@ -0,0 +1,16 @@ +/** + * Number of columns a `.files-grid-view` (and the photos square grid share the + * idea) renders at a given container width. Mirrors the CSS + * `repeat(auto-fill, minmax(var(--grid-card-min), 1fr))` so a windowing list can + * compute row counts that match the browser's actual wrapping exactly. + * + * Card-min / gap track the tokens in `lib/styles/base/variables.css` and the + * ≤640px phone override in `lib/styles/ported/resourceList.css`. + */ +export function gridColumns(width: number): number { + if (width <= 0) return 1; + const mobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches; + const cardMin = mobile ? 140 : 200; + const gap = mobile ? 8 : 20; + return Math.max(1, Math.floor((width + gap) / (cardMin + gap))); +} diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 5422b912..184459be 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -34,6 +34,7 @@ import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; import FileViewer from '$lib/components/FileViewer.svelte'; import ListToolbar from '$lib/components/ListToolbar.svelte'; + import VirtualList from '$lib/components/VirtualList.svelte'; import MoveDialog from '$lib/components/MoveDialog.svelte'; import ShareDialog from '$lib/components/ShareDialog.svelte'; import WopiEditor from '$lib/components/WopiEditor.svelte'; @@ -51,6 +52,7 @@ } from '$lib/stores/files.svelte'; import { formatBytes } from '$lib/utils/format'; import { formatDate, iconNameFromClass } from '$lib/utils/display'; + import { gridColumns } from '$lib/utils/grid'; // The URL rest param is the trail of folder ids from home's children down. // /files → home root; /files/a/b → folder b inside a inside home. @@ -798,6 +800,16 @@ /** Flat id order matching how rows are displayed (folders then files). */ const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]); + // Folders-then-files as one ordered, discriminated list so the (flat) view can + // be windowed by a single VirtualList. Content width drives the grid columns. + type Entry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem }; + const entries = $derived([ + ...sortedFolders.map((folder) => ({ kind: 'folder' as const, folder })), + ...sortedFiles.map((file) => ({ kind: 'file' as const, file })) + ]); + const entryKey = (e: Entry): string => (e.kind === 'folder' ? e.folder.id : e.file.id); + let gridWidth = $state(0); + // ── Group-by / swimlanes ───────────────────────────────────────────────── // Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus // Type / Size / Modified date / Created date dimensions. Folders always group @@ -1088,49 +1100,10 @@ hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')} /> {:else} -
-
-
-
- 0 && selectedCount === totalCount} - indeterminate={selectedCount > 0 && selectedCount < totalCount} - onchange={toggleSelectAll} - /> -
- {#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)} - {#if col.f === 'owner'} -
{col.l}
- {:else} - - {/if} - {/each} -
-
- - {#if groupBy === ''} - {#each sortedFolders as folder (folder.id)} - {@render folderRow(folder)} - {/each} - {#each sortedFiles as file (file.id)} - {@render fileRow(file)} - {/each} - {:else} +
+ {#if groupBy !== ''} +
+ {@render fileListHeader()} {#each groups as group (group.key)}
{group.label}
{#each group.folders as folder (folder.id)} @@ -1140,12 +1113,71 @@ {@render fileRow(file)} {/each} {/each} - {/if} -
+
+ {:else if filesStore.viewMode === 'list'} + +
+ {@render fileListHeader()} + +
+ {:else} + + + {/if}
{/if}
+{#snippet fileListHeader()} +
+
+ 0 && selectedCount === totalCount} + indeterminate={selectedCount > 0 && selectedCount < totalCount} + onchange={toggleSelectAll} + /> +
+ {#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)} + {#if col.f === 'owner'} +
{col.l}
+ {:else} + + {/if} + {/each} +
+
+{/snippet} + +{#snippet entryRow(e: Entry)} + {#if e.kind === 'folder'} + {@render folderRow(e.folder)} + {:else} + {@render fileRow(e.file)} + {/if} +{/snippet} + {#snippet folderRow(folder: FolderItem)}
Date: Fri, 19 Jun 2026 15:00:14 +0000 Subject: [PATCH 4/9] 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 @@
From 9ccaeef0abc960ae77c4d776a04bdba231879ebf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:21:01 +0000 Subject: [PATCH 5/9] perf(listing): return per-item is_favorite/is_shared, drop client badge fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder listing now carries the favorite/share badge state for exactly the items it returns, so the files browser stops fetching favorites and outgoing shares separately. This removes the last per-navigation badge round-trips AND fixes the correctness hole of the previous approaches: badges were derived from only the first 200 global favorites / shares, so a favorited or shared item outside that window showed no badge. Now every listed item is correct, and the work is scoped to the items on screen. Backend (`GET /api/folders/{id}/listing`): - `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing- level metadata, so no churn to the many FileDto/FolderDto constructors. - The handler computes both with two batched, index-backed queries run concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and `PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY, which already covers public links as 'token' grants — same membership the /grants/outgoing/resources endpoint exposes). Both fold into the ETag. - Public-share browsing passes empty sets (anonymous, read-only context). Frontend: - `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local badge sets straight from the listing and updates them optimistically on favorite toggle / batch / share creation (via ShareDialog's `onshared`). - Removes the session `badges` store + its fetches entirely — the listing is now the single, authoritative, fetch-free source. Net: favorite/share badges cost zero extra client requests per navigation and are correct regardless of how many favorites/shares the user has. Validated: cargo check + clippy -D warnings (backend; integration tests need Postgres, unavailable here), frontend npm run check + unit tests, and a headless render of the real files route (list + grid) with the new flags present — no errors. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- frontend/src/lib/api/endpoints/folders.ts | 15 +++- frontend/src/lib/stores/badges.svelte.ts | 73 ------------------ frontend/src/lib/stores/badges.test.ts | 75 ------------------- .../src/routes/files/[...path]/+page.svelte | 60 +++++++++------ src/application/dtos/folder_listing_dto.rs | 7 ++ src/application/services/favorites_service.rs | 11 +++ .../services/share_browse_service.rs | 4 + src/infrastructure/services/pg_acl_engine.rs | 29 +++++++ src/interfaces/api/handlers/folder_handler.rs | 56 +++++++++++++- 9 files changed, 156 insertions(+), 174 deletions(-) delete mode 100644 frontend/src/lib/stores/badges.svelte.ts delete mode 100644 frontend/src/lib/stores/badges.test.ts diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index 308ac07f..f2863b01 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -13,6 +13,10 @@ const NO_CACHE: RequestInit = { export interface FolderListing { folders: FolderItem[]; files: FileItem[]; + /** Ids in this listing the caller has favorited (server-computed badge set). */ + favoriteIds: string[]; + /** Ids in this listing the caller has an outgoing share/grant on. */ + sharedIds: string[]; } /** Top-level folders for the user; the first entry is the home folder. */ @@ -37,10 +41,17 @@ export async function listFolder(folderId: string, forceRefresh = false): Promis const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers }); if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); if (!res.ok) throw new Error(`listing failed: ${res.status}`); - const listing = (await res.json()) as Partial; + const listing = (await res.json()) as { + folders?: FolderItem[]; + files?: FileItem[]; + favorite_ids?: string[]; + shared_ids?: string[]; + }; return { folders: Array.isArray(listing.folders) ? listing.folders : [], - files: Array.isArray(listing.files) ? listing.files : [] + files: Array.isArray(listing.files) ? listing.files : [], + favoriteIds: Array.isArray(listing.favorite_ids) ? listing.favorite_ids : [], + sharedIds: Array.isArray(listing.shared_ids) ? listing.shared_ids : [] }; } diff --git a/frontend/src/lib/stores/badges.svelte.ts b/frontend/src/lib/stores/badges.svelte.ts deleted file mode 100644 index 8b081ee0..00000000 --- a/frontend/src/lib/stores/badges.svelte.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 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 deleted file mode 100644 index 0bfc3f26..00000000 --- a/frontend/src/lib/stores/badges.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 3290fd01..09842a51 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -39,7 +39,6 @@ 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'; @@ -58,7 +57,7 @@ // /files → home root; /files/a/b → folder b inside a inside home. const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0)); - let listing = $state({ folders: [], files: [] }); + let listing = $state({ folders: [], files: [], favoriteIds: [], sharedIds: [] }); let crumbs = $state>([]); let currentId = $state(null); let loading = $state(false); @@ -80,6 +79,12 @@ let actionTarget = $state(null); let moveItems = $state(null); + // Favorite + shared badge sets for the current folder, seeded directly from + // the listing response (server-computed, scoped to these items — no extra + // per-navigation fetch) and updated optimistically on mutation. + let favoriteIds = $state>(new Set()); + let sharedIds = $state>(new Set()); + function openMove(kind: ItemType, id: string, name: string) { actionTarget = { id, name, kind }; moveItems = null; @@ -98,15 +103,21 @@ } async function toggleFavorite(kind: ItemType, id: string) { - const isFav = badges.isFavorite(id); + const isFav = favoriteIds.has(id); // Optimistic toggle, reverted on failure. - badges.setFavorite(id, !isFav); + const next = new Set(favoriteIds); + if (isFav) next.delete(id); + else next.add(id); + favoriteIds = next; try { if (isFav) await removeFavorite(kind, id); else await addFavorite(kind, id); } catch (e) { errorToast(e); - badges.setFavorite(id, isFav); + const reverted = new Set(favoriteIds); + if (isFav) reverted.add(id); + else reverted.delete(id); + favoriteIds = reverted; } } @@ -147,7 +158,8 @@ const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]); listing = data; crumbs = trail; - void badges.ensureLoaded(); + favoriteIds = new Set(data.favoriteIds); + sharedIds = new Set(data.sharedIds); maybeOpenDeepLink(); } catch (e) { // 403 → friendly message rather than the raw "Forbidden" error string. @@ -423,7 +435,7 @@ /** Batch add the selection to favorites — single /api/favorites/batch call. */ async function batchFavorites() { - const items = selectionTargets().filter((it) => !badges.isFavorite(it.id)); + const items = selectionTargets().filter((it) => !favoriteIds.has(it.id)); if (items.length === 0) { ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info'); clearSelection(); @@ -439,7 +451,7 @@ }) }); if (!res.ok) throw new Error(`Server returned ${res.status}`); - for (const it of items) badges.setFavorite(it.id, true); + favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]); ui.notify(t('files.added_favorites', 'Added to favorites'), 'success'); clearSelection(); } catch (e) { @@ -1196,13 +1208,13 @@
{folder.name} - {#if badges.isFavorite(folder.id)}
{/if} - {#if badges.isShared(folder.id)}
@@ -1219,15 +1231,15 @@
diff --git a/src/application/dtos/folder_listing_dto.rs b/src/application/dtos/folder_listing_dto.rs index bdfc242f..7380ce85 100644 --- a/src/application/dtos/folder_listing_dto.rs +++ b/src/application/dtos/folder_listing_dto.rs @@ -12,4 +12,11 @@ pub struct FolderListingDto { pub folders: Vec, /// Files inside the requested folder pub files: Vec, + /// Ids (folders + files in this listing) the caller has favorited. Lets the + /// client render star badges without a separate per-navigation favorites + /// fetch. Sorted for a stable response / ETag. + pub favorite_ids: Vec, + /// Ids in this listing the caller has an outgoing share/grant on (incl. + /// public links). Sorted for a stable response / ETag. + pub shared_ids: Vec, } diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index 6ca17c54..e8970b42 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -27,6 +27,17 @@ impl FavoritesService { pub fn new(repo: Arc) -> Self { Self { repo } } + + /// Subset of `(item_id, item_type)` pairs the user has favorited — used to + /// stamp star badges onto a folder listing in one batched query (no N+1, no + /// global page fetch). + pub async fn favorited_ids( + &self, + user_id: Uuid, + items: &[(&str, &str)], + ) -> Result> { + self.repo.batch_check_favorites(user_id, items).await + } } impl FavoritesUseCase for FavoritesService { diff --git a/src/application/services/share_browse_service.rs b/src/application/services/share_browse_service.rs index 64f0bd92..a68d6685 100644 --- a/src/application/services/share_browse_service.rs +++ b/src/application/services/share_browse_service.rs @@ -186,6 +186,10 @@ impl ShareBrowseService { Ok(FolderListingDto { folders: folders_res?, files: files_res?, + // Public-share browsing is an anonymous, read-only context — no + // per-caller favorite/share badges apply. + favorite_ids: Vec::new(), + shared_ids: Vec::new(), }) } } diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 652d54e3..bc02550a 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -105,6 +105,35 @@ impl PgAclEngine { } } + /// Subset of `resource_ids` the caller has shared — i.e. has any outgoing + /// role grant on (a `user`/`group` grant or a `token` grant, the latter + /// being a public link). One batched query, mirroring the membership the + /// `/grants/outgoing/resources` endpoint exposes; used to stamp "shared" + /// badges onto a folder listing without a per-navigation grants fetch. + pub async fn shared_resource_ids( + &self, + granted_by: Uuid, + resource_ids: &[Uuid], + ) -> Result, DomainError> { + if resource_ids.is_empty() { + return Ok(HashSet::new()); + } + let rows: Vec<(Uuid,)> = sqlx::query_as( + r#" + SELECT DISTINCT resource_id + FROM storage.role_grants + WHERE granted_by = $1 + AND resource_id = ANY($2) + "#, + ) + .bind(granted_by) + .bind(resource_ids) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("shared_resource_ids: {e}")))?; + Ok(rows.into_iter().map(|(id,)| id).collect()) + } + /// Creates a stub instance for tests that need to construct services /// without a real PostgreSQL pool. Connecting to the lazy pool will /// fail at runtime — only safe in tests that exercise types, not actual diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index da113be5..ff1160b7 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -169,6 +169,8 @@ impl FolderHandler { fn compute_listing_etag( folders: &[crate::application::dtos::folder_dto::FolderDto], files: &[crate::application::dtos::file_dto::FileDto], + favorite_ids: &[String], + shared_ids: &[String], ) -> String { let max_mod = folders .iter() @@ -180,6 +182,10 @@ impl FolderHandler { let mut hasher = std::collections::hash_map::DefaultHasher::new(); max_mod.hash(&mut hasher); count.hash(&mut hasher); + // Badge state is part of the representation — fold it in (both slices are + // sorted, so the hash is stable) so a favorite/share change busts the ETag. + favorite_ids.hash(&mut hasher); + shared_ids.hash(&mut hasher); format!("\"{:x}\"", hasher.finish()) } @@ -205,7 +211,48 @@ impl FolderHandler { match (folders_result, files_result) { (Ok(folders), Ok(files)) => { - let etag = Self::compute_listing_etag(&folders, &files); + // Badge enrichment for this listing: which items the caller has + // favorited / shared. Two batched, index-backed queries (run + // concurrently) replace the client's old per-navigation global + // favorites + outgoing-shares fetches — correct (no 200-item + // ceiling) and scoped to just the items on screen. + let fav_pairs: Vec<(&str, &str)> = folders + .iter() + .map(|f| (f.id.as_str(), "folder")) + .chain(files.iter().map(|f| (f.id.as_str(), "file"))) + .collect(); + let resource_uuids: Vec = folders + .iter() + .map(|f| f.id.as_str()) + .chain(files.iter().map(|f| f.id.as_str())) + .filter_map(|s| uuid::Uuid::parse_str(s).ok()) + .collect(); + + let (favorited, shared) = tokio::join!( + async { + match &state.favorites_service { + Some(svc) => svc + .favorited_ids(auth_user.id, &fav_pairs) + .await + .unwrap_or_default(), + None => Default::default(), + } + }, + state + .authorization + .shared_resource_ids(auth_user.id, &resource_uuids) + ); + + let mut favorite_ids: Vec = favorited.into_iter().collect(); + favorite_ids.sort(); + let mut shared_ids: Vec = shared + .unwrap_or_default() + .into_iter() + .map(|u| u.to_string()) + .collect(); + shared_ids.sort(); + + let etag = Self::compute_listing_etag(&folders, &files, &favorite_ids, &shared_ids); // 304 Not Modified if the client already has this version if let Some(inm) = headers.get(header::IF_NONE_MATCH) @@ -219,7 +266,12 @@ impl FolderHandler { .unwrap() .into_response(); } - let listing = FolderListingDto { folders, files }; + let listing = FolderListingDto { + folders, + files, + favorite_ids, + shared_ids, + }; let mut resp = (StatusCode::OK, Json(listing)).into_response(); resp.headers_mut() .insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap()); From 3125c866c7ebcbc157425982d8a569a3ba449e92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:39:08 +0000 Subject: [PATCH 6/9] perf(files): stale-while-revalidate folder listings with conditional ETag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every folder navigation re-downloaded the full listing: `listFolder` cache-busted with `?t=` + `Cache-Control: no-store`, so back/forward and re-entering a folder always paid a full round-trip + payload. Now the files browser caches listings in memory and serves SWR: - On navigation it paints a previously-visited folder instantly from cache, then revalidates with `If-None-Match` (the backend ETag covers folders + files + favorite/share badges, so it's a faithful validator). Unchanged → 304 with an empty body; changed → 200 refreshes cache + UI. - A generation token guards against a slow in-flight response clobbering a newer navigation; breadcrumbs now resolve independently so they never block the grid paint. - Mutations (create/upload/rename/move/copy/delete, incl. the move dialog) go through `reload()`, which drops the cache and refetches fresh — no stale view after an action. API layer (`folders.ts`): - `fetchFolderListing(id, { etag?, forceRefresh? })` does the conditional fetch (200 → parsed listing + ETag, 304 → empty); `listFolder` stays as a non-conditional wrapper for the move-dialog tree. - A small LRU (cap 40) cache with `getCachedFolder` / `cacheFolder` / `invalidateFolderCache`. `cache: 'no-store'` keeps the browser HTTP cache out of the way; revalidation is driven entirely by our own ETag. Net: instant back/forward navigation, and an unchanged folder revalidates with a 0-byte 304 instead of re-downloading the whole listing. Validated: 7 new unit tests (conditional If-None-Match + 304, LRU eviction/recency, invalidation), npm run check, and a headless render of the real files route (list + grid). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- .../src/lib/api/endpoints/folders.test.ts | 108 ++++++++++++++++ frontend/src/lib/api/endpoints/folders.ts | 104 ++++++++++++--- .../src/routes/files/[...path]/+page.svelte | 122 ++++++++++++------ 3 files changed, 277 insertions(+), 57 deletions(-) create mode 100644 frontend/src/lib/api/endpoints/folders.test.ts diff --git a/frontend/src/lib/api/endpoints/folders.test.ts b/frontend/src/lib/api/endpoints/folders.test.ts new file mode 100644 index 00000000..703ed19f --- /dev/null +++ b/frontend/src/lib/api/endpoints/folders.test.ts @@ -0,0 +1,108 @@ +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 { + fetchFolderListing, + getCachedFolder, + cacheFolder, + invalidateFolderCache, + type FolderListing +} from './folders'; + +type RawListing = { + folders?: unknown[]; + files?: unknown[]; + favorite_ids?: string[]; + shared_ids?: string[]; +}; + +function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response { + return { + status: opts.status, + ok: opts.status >= 200 && opts.status < 300, + json: async () => opts.body ?? {}, + headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) } + } as unknown as Response; +} + +const emptyListing = (): FolderListing => ({ + folders: [], + files: [], + favoriteIds: [], + sharedIds: [] +}); + +const initHeaders = (call: number): Record => + (vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record; + +beforeEach(() => { + vi.clearAllMocks(); + invalidateFolderCache(); +}); + +describe('fetchFolderListing (conditional)', () => { + it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => { + vi.mocked(apiFetch).mockResolvedValue( + fakeRes({ + status: 200, + body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] }, + etag: '"v1"' + }) + ); + const r = await fetchFolderListing('f1'); + expect(r.status).toBe(200); + expect(r.etag).toBe('"v1"'); + expect(r.listing?.favoriteIds).toEqual(['a']); + expect(r.listing?.sharedIds).toEqual(['b']); + expect(initHeaders(0)['If-None-Match']).toBeUndefined(); + // No cache-busting query param — the URL must be stable for revalidation. + expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe('/api/folders/f1/listing'); + }); + + it('sends If-None-Match and surfaces a 304 with no body', async () => { + vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 })); + const r = await fetchFolderListing('f1', { etag: '"v1"' }); + expect(r.status).toBe(304); + expect(r.listing).toBeUndefined(); + expect(initHeaders(0)['If-None-Match']).toBe('"v1"'); + }); + + it('throws a 403 carrying its status', async () => { + vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 403 })); + await expect(fetchFolderListing('f1')).rejects.toMatchObject({ status: 403 }); + }); +}); + +describe('folder listing cache (LRU + invalidation)', () => { + it('stores and retrieves a listing + its ETag', () => { + cacheFolder('a', emptyListing(), '"1"'); + expect(getCachedFolder('a')?.etag).toBe('"1"'); + expect(getCachedFolder('missing')).toBeUndefined(); + }); + + it('evicts the least-recently-used entry past the cap', () => { + for (let i = 0; i < 45; i++) cacheFolder(`f${i}`, emptyListing()); + expect(getCachedFolder('f0')).toBeUndefined(); // evicted (cap is 40) + expect(getCachedFolder('f44')).toBeDefined(); + }); + + it('a read bumps recency so the touched entry survives eviction', () => { + for (let i = 0; i < 40; i++) cacheFolder(`f${i}`, emptyListing()); + getCachedFolder('f0'); // bump f0 to most-recent + cacheFolder('extra', emptyListing()); // forces one eviction + expect(getCachedFolder('f0')).toBeDefined(); + expect(getCachedFolder('f1')).toBeUndefined(); // f1 was now the oldest + }); + + it('invalidates a single folder, or the whole cache', () => { + cacheFolder('a', emptyListing()); + cacheFolder('b', emptyListing()); + invalidateFolderCache('a'); + expect(getCachedFolder('a')).toBeUndefined(); + expect(getCachedFolder('b')).toBeDefined(); + invalidateFolderCache(); + expect(getCachedFolder('b')).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index f2863b01..ae9feefa 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -19,6 +19,66 @@ export interface FolderListing { sharedIds: string[]; } +/** Result of a (possibly conditional) listing fetch. */ +export interface FolderListingResult { + /** 200 with a fresh `listing`, or 304 → the caller should keep its cache. */ + status: number; + listing?: FolderListing; + etag?: string; +} + +// ── In-memory listing cache (stale-while-revalidate) ───────────────────────── +// Lets the files view paint a previously-visited folder instantly on +// back/forward navigation, then revalidate with `If-None-Match` (304 = no body). +interface CachedFolder { + listing: FolderListing; + etag?: string; +} +const FOLDER_CACHE_MAX = 40; +const folderCache = new Map(); + +/** Cached listing for a folder, bumped to most-recently-used. */ +export function getCachedFolder(folderId: string): CachedFolder | undefined { + const hit = folderCache.get(folderId); + if (hit) { + folderCache.delete(folderId); + folderCache.set(folderId, hit); + } + return hit; +} + +export function cacheFolder(folderId: string, listing: FolderListing, etag?: string): void { + folderCache.delete(folderId); + folderCache.set(folderId, { listing, etag }); + // Evict the least-recently-used entries past the cap. + while (folderCache.size > FOLDER_CACHE_MAX) { + const oldest = folderCache.keys().next().value; + if (oldest === undefined) break; + folderCache.delete(oldest); + } +} + +/** Drop one folder, or the whole cache (no id), after a mutation. */ +export function invalidateFolderCache(folderId?: string): void { + if (folderId === undefined) folderCache.clear(); + else folderCache.delete(folderId); +} + +function parseListing(raw: unknown): FolderListing { + const o = (raw ?? {}) as { + folders?: FolderItem[]; + files?: FileItem[]; + favorite_ids?: string[]; + shared_ids?: string[]; + }; + return { + folders: Array.isArray(o.folders) ? o.folders : [], + files: Array.isArray(o.files) ? o.files : [], + favoriteIds: Array.isArray(o.favorite_ids) ? o.favorite_ids : [], + sharedIds: Array.isArray(o.shared_ids) ? o.shared_ids : [] + }; +} + /** Top-level folders for the user; the first entry is the home folder. */ export function listRootFolders(): Promise { return apiJson('/api/folders', { credentials: 'same-origin' }); @@ -28,33 +88,41 @@ export function getFolder(id: string): Promise { return apiJson(`/api/folders/${id}`, NO_CACHE); } -export async function listFolder(folderId: string, forceRefresh = false): Promise { - const ts = Math.floor(Date.now() / 1000); - let url = `/api/folders/${folderId}/listing?t=${ts}`; - const headers: Record = { - 'Cache-Control': 'no-cache, no-store, must-revalidate' - }; - if (forceRefresh) { - url += '&force_refresh=true'; +/** + * Fetch a folder listing, optionally conditionally. With `etag` set it sends + * `If-None-Match`; the server replies 304 (empty body) when nothing changed — + * the ETag covers folders + files + favorite/share badges — so the caller can + * keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of + * the way; revalidation is driven entirely by our own ETag. + */ +export async function fetchFolderListing( + folderId: string, + opts: { etag?: string; forceRefresh?: boolean } = {} +): Promise { + const headers: Record = {}; + if (opts.etag) headers['If-None-Match'] = opts.etag; + let url = `/api/folders/${folderId}/listing`; + if (opts.forceRefresh) { + url += '?force_refresh=true'; headers['X-Force-Refresh'] = 'true'; } const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers }); + if (res.status === 304) return { status: 304 }; if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); if (!res.ok) throw new Error(`listing failed: ${res.status}`); - const listing = (await res.json()) as { - folders?: FolderItem[]; - files?: FileItem[]; - favorite_ids?: string[]; - shared_ids?: string[]; - }; return { - folders: Array.isArray(listing.folders) ? listing.folders : [], - files: Array.isArray(listing.files) ? listing.files : [], - favoriteIds: Array.isArray(listing.favorite_ids) ? listing.favorite_ids : [], - sharedIds: Array.isArray(listing.shared_ids) ? listing.shared_ids : [] + status: 200, + listing: parseListing(await res.json()), + etag: res.headers.get('ETag') ?? undefined }; } +/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */ +export async function listFolder(folderId: string, forceRefresh = false): Promise { + const res = await fetchFolderListing(folderId, { forceRefresh }); + return res.listing ?? { folders: [], files: [], favoriteIds: [], sharedIds: [] }; +} + export async function createFolder(name: string, parentId: string | null): Promise { const res = await apiFetch('/api/folders', { method: 'POST', diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 09842a51..0f9e9c0a 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -6,10 +6,13 @@ import { page } from '$app/state'; import Icon from '$lib/icons/Icon.svelte'; import { + cacheFolder, createFolder, deleteFolder, + fetchFolderListing, + getCachedFolder, getFolder, - listFolder, + invalidateFolderCache, moveFolder, renameFolder, type FolderListing @@ -133,50 +136,91 @@ return metas; } + // Bumped on every load; a stale in-flight response checks this before it + // writes state, so a fast navigation can't be clobbered by an older fetch. + let loadSeq = 0; + + function applyListing(data: FolderListing) { + listing = data; + favoriteIds = new Set(data.favoriteIds); + sharedIds = new Set(data.sharedIds); + } + async function load() { - loading = true; error = null; - // Arm the delayed skeleton; cancel it the moment the load settles so fast - // loads never flash placeholders (mirrors filesView.js' 100ms timer). + const seq = ++loadSeq; + + // External users have no home folder; send them to shared-with-me. + if (session.isExternalUser && pathSegments.length === 0) { + await goto('/shared-with-me', { replaceState: true }); + return; + } + const home = await session.loadHomeFolder(); + const folderId = pathSegments.at(-1) ?? home; + if (!folderId) { + error = t('files.no_home', 'No home folder available.'); + return; + } + currentId = folderId; + filesStore.currentFolder = folderId; + + // Stale-while-revalidate: paint a previously-visited folder instantly, + // then revalidate with If-None-Match (304 = keep what's shown). + const cached = getCachedFolder(folderId); + if (cached) { + applyListing(cached.listing); + loading = false; + showSkeleton = false; + } else { + loading = true; + } + // Delayed skeleton, only when there's nothing cached to show yet. const skeletonTimer = setTimeout(() => { if (loading) showSkeleton = true; }, 100); + + // Breadcrumbs resolve independently so they never block the grid paint. + void buildCrumbs(pathSegments).then((trail) => { + if (seq === loadSeq) crumbs = trail; + }); + try { - // External users have no home folder; send them to shared-with-me. - if (session.isExternalUser && pathSegments.length === 0) { - await goto('/shared-with-me', { replaceState: true }); - return; + const res = await fetchFolderListing(folderId, { etag: cached?.etag }); + if (seq !== loadSeq) return; // superseded by a newer navigation + if (res.status === 200 && res.listing) { + applyListing(res.listing); + cacheFolder(folderId, res.listing, res.etag); } - const home = await session.loadHomeFolder(); - const folderId = pathSegments.at(-1) ?? home; - if (!folderId) { - error = t('files.no_home', 'No home folder available.'); - return; - } - currentId = folderId; - filesStore.currentFolder = folderId; - const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]); - listing = data; - crumbs = trail; - favoriteIds = new Set(data.favoriteIds); - sharedIds = new Set(data.sharedIds); + // 304 → the cached copy already on screen is current. + error = null; maybeOpenDeepLink(); } catch (e) { - // 403 → friendly message rather than the raw "Forbidden" error string. - const status = (e as { status?: number })?.status; - error = - status === 403 - ? t('errors.forbidden', 'Could not load files') - : e instanceof Error - ? e.message - : String(e); + if (seq !== loadSeq) return; + // With a cached view already shown, keep it on a transient failure. + if (!cached) { + const status = (e as { status?: number })?.status; + error = + status === 403 + ? t('errors.forbidden', 'Could not load files') + : e instanceof Error + ? e.message + : String(e); + } } finally { clearTimeout(skeletonTimer); - loading = false; - showSkeleton = false; + if (seq === loadSeq) { + loading = false; + showSkeleton = false; + } } } + /** Data changed — drop cached listings and reload the current folder fresh. */ + async function reload() { + invalidateFolderCache(); + await load(); + } + /** * Deep-link auto-open: when the URL carries `?file=` and that file is in * the freshly loaded listing, open it in the viewer (ported from @@ -207,7 +251,7 @@ if (!name) return; try { await createFolder(name, currentId); - await load(); + await reload(); } catch (e) { errorToast(e); } @@ -253,7 +297,7 @@ ) : t('files.uploaded', 'Upload complete'); ui.finishProgress(nid, done, 'success'); - await load(); + await reload(); } catch (err) { ui.finishProgress(nid, errorMessage(err), 'error'); } finally { @@ -286,7 +330,7 @@ try { if (kind === 'file') await renameFile(id, name); else await renameFolder(id, name); - await load(); + await reload(); } catch (e) { errorToast(e); } @@ -303,7 +347,7 @@ try { if (kind === 'file') await deleteFile(id); else await deleteFolder(id); - await load(); + await reload(); } catch (e) { errorToast(e); } @@ -522,7 +566,7 @@ } } clearSelection(); - await load(); + await reload(); } // ── Drag-to-move ───────────────────────────────────────────────────────── @@ -561,7 +605,7 @@ else await moveFolder(it.id, targetFolderId); } clearSelection(); - await load(); + await reload(); } catch (err) { errorToast(err); } @@ -738,7 +782,7 @@ await uploadFile(dirId, file); } ui.notify(t('files.uploaded', 'Upload complete'), 'success'); - await load(); + await reload(); } catch (err) { errorToast(err); } finally { @@ -1418,7 +1462,7 @@ mode={moveMode} onmoved={() => { clearSelection(); - void load(); + void reload(); }} /> Date: Fri, 19 Jun 2026 16:12:20 +0000 Subject: [PATCH 7/9] 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); From a8709b447add4bfd8a29f020d9f5cb089d76ec49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 16:27:58 +0000 Subject: [PATCH 8/9] perf(i18n): load the English fallback lazily, off the startup critical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initI18n` runs in the client `init()` hook and blocks the first render. For every non-English user it awaited TWO locale dictionaries back to back — the active locale AND `en` (the fallback) — so first paint waited on two sequential round-trips + JSON parses. Now it awaits only the active locale, then warms `en` in the background (non-blocking). `t()` only consults `dicts.en` for keys the active locale is missing, and most call sites already pass an inline English fallback, so the deferred `en` doesn't change what users see; when it arrives `dicts.en` is reactive, so any key that fell through re-renders. English users are unchanged (no second fetch was ever needed). Net: non-English startup drops from two blocking locale fetches to one, halving the i18n payload on the critical path (the server already serves these JSONs brotli/gzip-compressed via the global CompressionLayer, so the wire cost was already small — this removes the extra round-trip + parse from first paint). Validated: new unit test (initI18n resolves while the en fetch is still pending, en is kicked off in the background, and a key missing from the active locale falls back once en lands) → 47 frontend tests green; npm run check; prod build. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- frontend/src/lib/i18n/i18n.test.ts | 52 +++++++++++++++++++++++++-- frontend/src/lib/i18n/index.svelte.ts | 6 +++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/i18n/i18n.test.ts b/frontend/src/lib/i18n/i18n.test.ts index 4bc2951b..5447fe14 100644 --- a/frontend/src/lib/i18n/i18n.test.ts +++ b/frontend/src/lib/i18n/i18n.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from 'vitest'; -import { getNestedValue, interpolate, resolveBrowserLocale } from './index.svelte'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + getNestedValue, + interpolate, + resolveBrowserLocale, + initI18n, + t, + i18n +} from './index.svelte'; describe('resolveBrowserLocale', () => { it('matches an exact full tag', () => { @@ -69,3 +76,44 @@ describe('interpolate', () => { expect(interpolate('{{count}} items', { count: 5 })).toBe('5 items'); }); }); + +describe('initI18n — lazy English fallback', () => { + let resolveEn: () => void; + + beforeEach(() => { + localStorage.setItem('oxicloud-locale', 'es'); + resolveEn = () => {}; + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/es.json')) { + return Promise.resolve(new Response(JSON.stringify({ greeting: 'Hola' }), { status: 200 })); + } + if (url.includes('/en.json')) { + // Deferred: only resolves when the test flips it, proving init didn't wait. + return new Promise((res) => { + resolveEn = () => + res(new Response(JSON.stringify({ only_en: 'English only' }), { status: 200 })); + }); + } + return Promise.resolve(new Response('{}', { status: 404 })); + }) as unknown as typeof fetch; + }); + + it('is ready after only the active locale and warms en in the background', async () => { + // Resolves even though the en fetch is still pending — it isn't awaited. + await initI18n(); + expect(i18n.loaded).toBe(true); + expect(i18n.locale).toBe('es'); + expect(t('greeting')).toBe('Hola'); + + const urls = vi.mocked(globalThis.fetch).mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes('/es.json'))).toBe(true); + expect(urls.some((u) => u.includes('/en.json'))).toBe(true); // en was kicked off + + // A key missing from es is unresolved until en arrives, then falls back. + expect(t('only_en')).toBe('only_en'); + resolveEn(); + await new Promise((r) => setTimeout(r, 0)); + expect(t('only_en')).toBe('English only'); + }); +}); diff --git a/frontend/src/lib/i18n/index.svelte.ts b/frontend/src/lib/i18n/index.svelte.ts index a15b8a50..9a80169a 100644 --- a/frontend/src/lib/i18n/index.svelte.ts +++ b/frontend/src/lib/i18n/index.svelte.ts @@ -207,9 +207,13 @@ export async function initI18n(): Promise { store.locale = saved; } await loadDict(store.locale); - if (store.locale !== 'en') await loadDict('en'); applyHtmlLang(store.locale); store.loaded = true; + // Warm the English fallback in the background. `t()` only consults it for + // keys the active (complete) locale is missing — and most call sites already + // pass an inline English fallback — so it must not block first paint. When it + // arrives, `dicts.en` is reactive, so any key that fell through re-renders. + if (store.locale !== 'en') void loadDict('en'); } export async function setLocale(locale: Locale): Promise { From a211d9d4a6285f1ee3265352c1b9463768173193 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 16:36:54 +0000 Subject: [PATCH 9/9] perf(boot): instant HTML splash in the shell for a faster perceived first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app is a pure client-rendered SPA (ssr=false, adapter-static), so until the JS bundle downloads, parses and mounts, the page is blank — only then does the layout's own loading state appear. True route prerendering would mean turning on SSR, an architectural change at odds with the SPA design. Instead, bake a tiny boot splash into the shell (`app.html`): a centered spinner that paints the moment the HTML is parsed — before the app bundle or its CSS load — covering the blank gap. The root layout removes `#app-splash` the instant it mounts (before `session.load`), so public routes like /login (which render without waiting for the session) appear immediately and protected routes hand off to their own loading UI. `light-dark()` plus early `color-scheme` rules make the splash match the resolved theme (incl. the saved override), so there's no colour flash when the app CSS arrives; it respects `prefers-reduced-motion`. Pure HTML/CSS in the shell — no new requests, no JS framework on the critical path, ~0.6 KB in index.html. Verified on the real static-dist build in headless Chromium: the shell ships `#app-splash`, the layout removes it after mount, the app renders, and there are no runtime errors. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6 --- frontend/src/app.html | 44 ++++++++++++++++++++++++++++++ frontend/src/routes/+layout.svelte | 5 ++++ 2 files changed, 49 insertions(+) diff --git a/frontend/src/app.html b/frontend/src/app.html index 4d9426c2..eb5e0e1f 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -23,9 +23,53 @@ } })(); + + %sveltekit.head% +
+
+
%sveltekit.body%
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 7f037227..68c3b8c5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -21,6 +21,11 @@ let ready = $state(false); onMount(async () => { + // The instant HTML boot splash has done its job — the app is mounted, so + // the route (login renders immediately; protected routes show their own + // loading state) is already in the DOM behind it. + document.getElementById('app-splash')?.remove(); + // Redirect old `#/...` bookmarks to the new path before anything else. if (typeof location !== 'undefined' && location.hash.startsWith('#/')) { const mapped = hashUrlToPath(location.hash);