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 <main> 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:14:04 +03:00
parent ffb8848a96
commit dc1af8c585
6 changed files with 35 additions and 48 deletions
@@ -7,23 +7,44 @@
let { loading = false, hasMore = true, onLoadMore }: Props = $props(); let { loading = false, hasMore = true, onLoadMore }: Props = $props();
// Lookahead distance below the viewport at which we start loading.
const MARGIN = 300;
let sentinel = $state<HTMLDivElement | undefined>(); let sentinel = $state<HTMLDivElement | undefined>();
// 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 <main> 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(() => { $effect(() => {
if (!sentinel) return; if (!sentinel) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && !loading && hasMore) { if (entries[0].isIntersecting) maybeLoad();
onLoadMore();
}
}, },
{ rootMargin: '300px' }, { rootMargin: `${MARGIN}px` },
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); 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();
});
</script> </script>
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div> <div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
@@ -59,4 +80,4 @@
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
</style> </style>
+1 -9
View File
@@ -3,7 +3,6 @@
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { categorySorting, type CategorySortField } from '$lib/stores/sorting'; import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { tick } from 'svelte';
import type { Category, CategoryOffsetPage } from '$lib/api/types'; import type { Category, CategoryOffsetPage } from '$lib/api/types';
const LIMIT = 100; const LIMIT = 100;
@@ -15,7 +14,6 @@
]; ];
let categories = $state<Category[]>([]); let categories = $state<Category[]>([]);
let scrollContainer = $state<HTMLElement | undefined>();
let total = $state(0); let total = $state(0);
let offset = $state(0); let offset = $state(0);
let loading = $state(false); let loading = $state(false);
@@ -64,12 +62,6 @@
loading = false; loading = false;
initialLoaded = true; 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); let hasMore = $derived(categories.length < total);
@@ -134,7 +126,7 @@
</div> </div>
</div> </div>
<main bind:this={scrollContainer}> <main>
{#if error} {#if error}
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}
+3 -6
View File
@@ -203,12 +203,9 @@
} finally { } finally {
loading = false; loading = false;
} }
// If the loaded content doesn't fill the viewport yet (no scrollbar), // Viewport filling is handled by InfiniteScroll, which re-checks after each
// keep loading until it does or there's nothing left. // load — no manual recursion (which over-fetched here because <main> isn't
await tick(); // the scroller, so its scrollHeight never exceeds its clientHeight).
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
void loadMore();
}
} }
function applyFilter(filter: string | null) { function applyFilter(filter: string | null) {
+1 -8
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { tick } from 'svelte';
import FileCard from '$lib/components/file/FileCard.svelte'; import FileCard from '$lib/components/file/FileCard.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@@ -9,8 +8,6 @@
import { appSettings } from '$lib/stores/appSettings'; import { appSettings } from '$lib/stores/appSettings';
import type { File, FileCursorPage } from '$lib/api/types'; import type { File, FileCursorPage } from '$lib/api/types';
let scrollContainer = $state<HTMLElement | undefined>();
let LIMIT = $derived($appSettings.fileLoadLimit); let LIMIT = $derived($appSettings.fileLoadLimit);
let files = $state<File[]>([]); let files = $state<File[]>([]);
@@ -47,10 +44,6 @@
loading = false; loading = false;
initialLoaded = true; initialLoaded = true;
} }
await tick();
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
void loadMore();
}
} }
// ---- Selection ---- // ---- Selection ----
@@ -165,7 +158,7 @@
</button> </button>
</header> </header>
<main bind:this={scrollContainer}> <main>
{#if error} {#if error}
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}
+1 -9
View File
@@ -3,7 +3,6 @@
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { poolSorting, type PoolSortField } from '$lib/stores/sorting'; import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { tick } from 'svelte';
import type { Pool, PoolOffsetPage } from '$lib/api/types'; import type { Pool, PoolOffsetPage } from '$lib/api/types';
const LIMIT = 50; const LIMIT = 50;
@@ -14,7 +13,6 @@
]; ];
let pools = $state<Pool[]>([]); let pools = $state<Pool[]>([]);
let scrollContainer = $state<HTMLElement | undefined>();
let total = $state(0); let total = $state(0);
let offset = $state(0); let offset = $state(0);
let loading = $state(false); let loading = $state(false);
@@ -63,12 +61,6 @@
loading = false; loading = false;
initialLoaded = true; 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(pools.length < total); let hasMore = $derived(pools.length < total);
@@ -137,7 +129,7 @@
</div> </div>
</div> </div>
<main bind:this={scrollContainer}> <main>
{#if error} {#if error}
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}
+1 -9
View File
@@ -4,7 +4,6 @@
import { tagSorting, type TagSortField } from '$lib/stores/sorting'; import { tagSorting, type TagSortField } from '$lib/stores/sorting';
import TagBadge from '$lib/components/tag/TagBadge.svelte'; import TagBadge from '$lib/components/tag/TagBadge.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { tick } from 'svelte';
import type { Tag, TagOffsetPage } from '$lib/api/types'; import type { Tag, TagOffsetPage } from '$lib/api/types';
const LIMIT = 100; const LIMIT = 100;
@@ -17,7 +16,6 @@
]; ];
let tags = $state<Tag[]>([]); let tags = $state<Tag[]>([]);
let scrollContainer = $state<HTMLElement | undefined>();
let total = $state(0); let total = $state(0);
let offset = $state(0); let offset = $state(0);
let loading = $state(false); let loading = $state(false);
@@ -69,12 +67,6 @@
loading = false; loading = false;
initialLoaded = true; 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();
}
} }
function onSearch(e: Event) { function onSearch(e: Event) {
@@ -145,7 +137,7 @@
</div> </div>
</div> </div>
<main bind:this={scrollContainer}> <main>
{#if error} {#if error}
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}