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:
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user