perf(frontend): virtualize grid views + the files browser (list & grid)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
Claude
2026-06-19 14:48:37 +00:00
parent ccb85f53c0
commit 8794324c3d
5 changed files with 171 additions and 86 deletions
+48 -32
View File
@@ -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}
<div class="files-container">
<div class={viewClass} style="--files-list-columns: {columns}">
<div class="list-header">
{#if selectable}
<div class="select-cell">
<input
type="checkbox"
aria-label={t('common.select_all', 'Select all')}
checked={allSelected}
onchange={toggleSelectAll}
/>
</div>
{/if}
<div>{t('files.col_name', 'Name')}</div>
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
{#if onfavorite || actions}<div></div>{/if}
</div>
{#if grouped}
<div class="files-container" bind:clientWidth={gridWidth}>
{#if grouped}
<div class={viewClass} style="--files-list-columns: {columns}">
{@render listHeader()}
{#each sections as section (section.key)}
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
{#each section.rows as entry (entry.id)}
{@render row(entry)}
{/each}
{/each}
{:else if filesStore.viewMode === 'list'}
<!-- Flat list view: only the visible rows are mounted. The spacer keeps
the full scroll height so the end-of-list sentinel still fires. -->
</div>
{:else if filesStore.viewMode === 'list'}
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the
full scroll height so the end-of-list sentinel still fires. -->
<div class="files-list-view" style="--files-list-columns: {columns}">
{@render listHeader()}
<VirtualList {items} rowHeight={56} key={(e) => e.id} {row} />
{:else}
{#each items as entry (entry.id)}
{@render row(entry)}
{/each}
{/if}
</div>
</div>
{:else}
<!-- Grid view: the windowed list's inner element IS the card grid. -->
<VirtualList
{items}
columns={gridCols}
rowHeight={240}
windowClass="files-grid-view"
key={(e) => e.id}
{row}
/>
{/if}
{#if hasMore}
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
@@ -459,6 +453,28 @@
</div>
{/if}
{#snippet listHeader()}
<div class="list-header">
{#if selectable}
<div class="select-cell">
<input
type="checkbox"
aria-label={t('common.select_all', 'Select all')}
checked={allSelected}
onchange={toggleSelectAll}
/>
</div>
{/if}
<div>{t('files.col_name', 'Name')}</div>
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
{#if onfavorite || actions}<div></div>{/if}
</div>
{/snippet}
{#if ctxOpen && ctxEntry && contextActions}
<div
class="rl-ctx-scrim"
+14 -6
View File
@@ -64,12 +64,17 @@
const offsetY = $derived(firstRow * effRowH);
const visible = $derived(items.slice(startIndex, endIndex));
/** Single-column: adopt the real rendered row height once it's known. */
/**
* Adopt the real rendered row pitch once rows exist. For a grid (cols > 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();
});
</script>
@@ -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();
};
}
+16
View File
@@ -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)));
}