From ccb85f53c015d924b8fd101c0ae9f7ab6db7c4f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:25:12 +0000 Subject: [PATCH] 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;