From dc1af8c585b34121514830fbac545bf06dfd8b9c Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 00:14:04 +0300 Subject: [PATCH] fix(frontend): make infinite scroll viewport-relative, stop eager-loading Lazy load fetched the entire list at once: every list's loader had a "fill the viewport" recursion gated on scrollContainer.scrollHeight <= clientHeight, but
is not the scroller (the window/body is), so that condition is always true and it recursed through every page (with a 10-item window, ~all pages fired at once). Move the filling logic into InfiniteScroll and base it on the sentinel's viewport rect instead: load while the sentinel is within 300px of the viewport bottom, re-checked synchronously after each load. This works regardless of which element scrolls and loads only enough pages to reach past the viewport. Drop the per-page recursion (and now-unused scrollContainer refs / tick imports) from the files, trash, tags, categories and pools lists. Co-Authored-By: Claude Opus 4.8 --- .../components/common/InfiniteScroll.svelte | 35 +++++++++++++++---- frontend/src/routes/categories/+page.svelte | 10 +----- frontend/src/routes/files/+page.svelte | 9 ++--- frontend/src/routes/files/trash/+page.svelte | 9 +---- frontend/src/routes/pools/+page.svelte | 10 +----- frontend/src/routes/tags/+page.svelte | 10 +----- 6 files changed, 35 insertions(+), 48 deletions(-) diff --git a/frontend/src/lib/components/common/InfiniteScroll.svelte b/frontend/src/lib/components/common/InfiniteScroll.svelte index 7320bcc..5a94e34 100644 --- a/frontend/src/lib/components/common/InfiniteScroll.svelte +++ b/frontend/src/lib/components/common/InfiniteScroll.svelte @@ -7,23 +7,44 @@ let { loading = false, hasMore = true, onLoadMore }: Props = $props(); + // Lookahead distance below the viewport at which we start loading. + const MARGIN = 300; + let sentinel = $state(); + // Fire onLoadMore while the sentinel is within MARGIN px of the viewport + // bottom. Measuring the sentinel's viewport rect (rather than a scroll + // container's scrollHeight/clientHeight) makes this correct whether the page + // scrolls on
or on the window — and it loads exactly enough pages to + // reach past the viewport, instead of eagerly loading everything. + function maybeLoad() { + if (loading || !hasMore || !sentinel) return; + const rect = sentinel.getBoundingClientRect(); + if (rect.top <= window.innerHeight + MARGIN) { + onLoadMore(); + } + } + + // Load on scroll: the observer notifies us when the sentinel nears the viewport. $effect(() => { if (!sentinel) return; - const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && !loading && hasMore) { - onLoadMore(); - } + if (entries[0].isIntersecting) maybeLoad(); }, - { rootMargin: '300px' }, + { rootMargin: `${MARGIN}px` }, ); - observer.observe(sentinel); return () => observer.disconnect(); }); + + // After each load settles (loading → false), re-check synchronously: if the + // freshly appended content still didn't push the sentinel past the viewport, + // load again. This fills short pages without the throttled observer lagging + // and over-fetching. + $effect(() => { + if (!loading) maybeLoad(); + }); @@ -59,4 +80,4 @@ @keyframes spin { to { transform: rotate(360deg); } } - \ No newline at end of file + diff --git a/frontend/src/routes/categories/+page.svelte b/frontend/src/routes/categories/+page.svelte index 1d41806..00eaaea 100644 --- a/frontend/src/routes/categories/+page.svelte +++ b/frontend/src/routes/categories/+page.svelte @@ -3,7 +3,6 @@ import { api, ApiError } from '$lib/api/client'; import { categorySorting, type CategorySortField } from '$lib/stores/sorting'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; - import { tick } from 'svelte'; import type { Category, CategoryOffsetPage } from '$lib/api/types'; const LIMIT = 100; @@ -15,7 +14,6 @@ ]; let categories = $state([]); - let scrollContainer = $state(); let total = $state(0); let offset = $state(0); let loading = $state(false); @@ -64,12 +62,6 @@ loading = false; initialLoaded = true; } - // Keep loading until the content fills the viewport so the infinite-scroll - // sentinel ends up below the fold; then stop. - await tick(); - if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) { - void load(); - } } let hasMore = $derived(categories.length < total); @@ -134,7 +126,7 @@ -
+
{#if error} {/if} diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 447caa8..04b7c5e 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -203,12 +203,9 @@ } finally { loading = false; } - // If the loaded content doesn't fill the viewport yet (no scrollbar), - // keep loading until it does or there's nothing left. - await tick(); - if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) { - void loadMore(); - } + // Viewport filling is handled by InfiniteScroll, which re-checks after each + // load — no manual recursion (which over-fetched here because
isn't + // the scroller, so its scrollHeight never exceeds its clientHeight). } function applyFilter(filter: string | null) { diff --git a/frontend/src/routes/files/trash/+page.svelte b/frontend/src/routes/files/trash/+page.svelte index d1f5bab..bc95319 100644 --- a/frontend/src/routes/files/trash/+page.svelte +++ b/frontend/src/routes/files/trash/+page.svelte @@ -1,7 +1,6 @@