perf(web): batch folder counts, bounded scroll scan, adaptive thumbs, lazy preview
Six-item frontend performance pass on the SvelteKit app.
P1 — Move per-folder photo counts to a new sidecar endpoint and defer
the fetch to requestIdleCallback. The old client-side path fired one
/photos?count=1000 per folder from the browser (≈1 MB JSON × N folders)
on every cold sidebar mount; the new POST /api/sidecar/folders/counts
fans out over loopback with bounded concurrency and returns a single
{path: count} payload of a few KB.
P2 — Bound the visibleRange scroll-scan around the previous visible
band instead of sweeping every shell from index 0 on each scroll-rAF.
Falls back to a full sweep on cache miss (filter reset, programmatic
jump) so behaviour is unchanged at the edges.
P3 — Adaptive thumbnail size + srcset. PhotoTile now picks the smallest
PhotoPrism tile_* variant (100/224/500) that covers the user's grid
preset at the current DPR. Adds decoding="async".
P4 — Lift the selection check above the {#each} loop. Mostly readability
— SvelteSet.has() is already per-key reactive — but keeps the hot loop
body terse.
P5 — Split dedupedAll / photos derivations so filter-store mutations
(search-as-you-type, section toggles) don't re-walk every loaded page;
only the cheap folder-scope filter re-runs.
P6 — Dynamic-import PreviewOverlay on first preview.uid !== null and
cache the loaded module; closing the overlay leaves the component
mounted with its internal {#if} collapsing the DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -127,16 +127,33 @@
|
||||
);
|
||||
|
||||
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
|
||||
// in parallel. Key the query off the folder-path list so it refetches
|
||||
// when folders are added/renamed/deleted, and share the ['photos', …]
|
||||
// prefix so it invalidates alongside the other photo caches whenever a
|
||||
// mutation lands.
|
||||
// FileCount: 0 for every folder, so the sidecar /folders/counts
|
||||
// endpoint resolves them in one round-trip (see listFolderCounts).
|
||||
// Key the query off the folder-path list so it refetches when folders
|
||||
// are added/renamed/deleted, and share the ['photos', …] prefix so it
|
||||
// invalidates alongside the other photo caches whenever a mutation
|
||||
// lands.
|
||||
//
|
||||
// `countsReady` gates the query until just after the sidebar's first
|
||||
// paint. Even though the sidecar response is small, the per-folder
|
||||
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
|
||||
// blocking it on idle means the folder list paints immediately and
|
||||
// the count badges fade in instead of holding back the whole tree.
|
||||
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||
let countsReady = $state(false);
|
||||
if (browser) {
|
||||
const kick = () => (countsReady = true);
|
||||
// requestIdleCallback isn't in Safari yet; fall back to a short
|
||||
// timeout so the deferral is still bounded.
|
||||
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
|
||||
.requestIdleCallback;
|
||||
if (typeof ric === 'function') ric(kick);
|
||||
else setTimeout(kick, 200);
|
||||
}
|
||||
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||
queryFn: () => listFolderCounts(folderPaths),
|
||||
enabled: isAuthenticated() && folderPaths.length > 0,
|
||||
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Maximize2 } from 'lucide-svelte';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +30,14 @@
|
||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
||||
|
||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
|
||||
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
|
||||
// wider when the grid stretches to fill the column, but `tile_*` is
|
||||
// discrete (100/224/500) so the rounding-up at the next variant
|
||||
// boundary swallows the difference.
|
||||
const tilePx = $derived(view.thumbnailSize);
|
||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -68,9 +77,11 @@
|
||||
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
src={src1x}
|
||||
srcset={srcset}
|
||||
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
class:transition={!selected}
|
||||
class:group-hover:scale-105={!selected}
|
||||
|
||||
Reference in New Issue
Block a user