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:
@@ -4,6 +4,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import type { Component } from 'svelte';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
@@ -12,7 +13,7 @@
|
||||
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import PreviewOverlay from '$lib/components/preview/PreviewOverlay.svelte';
|
||||
import { preview } from '$lib/stores/preview.svelte';
|
||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||
|
||||
@@ -42,6 +43,23 @@
|
||||
void goto('/login', { replaceState: true });
|
||||
}
|
||||
});
|
||||
|
||||
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
|
||||
// pane, exif sidebar. Users who never click into a photo never need
|
||||
// it, so we lazy-import the first time `preview.uid` flips non-null
|
||||
// and keep the loaded module around for the rest of the session
|
||||
// (re-opens skip the network round-trip). Closing the overlay leaves
|
||||
// the component mounted but renders nothing — its internal
|
||||
// `{#if preview.uid !== null}` guard collapses the DOM tree.
|
||||
let PreviewOverlay = $state<Component | null>(null);
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (preview.uid !== null && PreviewOverlay === null) {
|
||||
void import('$lib/components/preview/PreviewOverlay.svelte').then((m) => {
|
||||
PreviewOverlay = m.default as Component;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -97,5 +115,7 @@
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
<PreviewOverlay />
|
||||
{#if PreviewOverlay}
|
||||
<PreviewOverlay />
|
||||
{/if}
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -143,16 +143,20 @@
|
||||
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
|
||||
* pages can repeat a photo when its file-row span straddles the offset
|
||||
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||||
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
||||
* handlers) treat this as the single source of truth.
|
||||
* preserves order.
|
||||
*
|
||||
* Split into two derived values so the heavy dedup pass only runs
|
||||
* when the query's pages array changes (i.e. a fetch landed). The
|
||||
* cheap folder-scope filter then runs whenever the user flips the
|
||||
* filter store — search-as-you-type, section toggle, root vs.
|
||||
* subfolder — without re-walking every page on each keystroke.
|
||||
*
|
||||
* When the user picks the root entry in the folder tree we filter
|
||||
* to `Path === ''` here — PhotoPrism's `path:` operator can't
|
||||
* express that match, so the query fetches the whole library and
|
||||
* we strip subfolder rows post-hoc. */
|
||||
const photos = $derived<PpPhoto[]>(
|
||||
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
|
||||
);
|
||||
const dedupedAll = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
|
||||
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
|
||||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||
if (!pages) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -287,6 +291,15 @@
|
||||
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER
|
||||
);
|
||||
|
||||
// Hoist the selection reads out of the per-tile each-block. The same
|
||||
// `has` / `===` semantics as `isSelected(uid) || selection.focused === uid`,
|
||||
// just expressed at the loop level so the render reads as "given this
|
||||
// (selected, focused) snapshot, here's each tile's state". No reactivity
|
||||
// change — SvelteSet's `has` already subscribes per-key — but it removes
|
||||
// the function-call indirection and keeps the hot loop body terse.
|
||||
const selectedIds = $derived(selection.ids);
|
||||
const focusedUid = $derived(selection.focused);
|
||||
|
||||
// Reset the window when the filter key changes — old indices from the
|
||||
// previous photo list otherwise pin renderFirst/renderLast outside the
|
||||
// new shorter list and the grid renders empty. Track `filtersToQ` as
|
||||
@@ -878,7 +891,7 @@
|
||||
use:tileRegister={i}
|
||||
>
|
||||
{#if inWindow}
|
||||
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
|
||||
{@const sel = selectedIds.has(photo.UID) || focusedUid === photo.UID}
|
||||
<PhotoTile
|
||||
{photo}
|
||||
selected={sel}
|
||||
|
||||
Reference in New Issue
Block a user