feat: fetch the next page of books before the reader hits the bottom

The prefetch zone is a viewport deep rather than 200px, and the observer
re-observes after a page lands so a chain of loads is not cut short.
This commit is contained in:
2026-08-17 13:48:40 -04:00
parent 55e00ba960
commit 0220f2d970
2 changed files with 45 additions and 16 deletions
@@ -29,19 +29,31 @@
$effect(() => { $effect(() => {
if (!sentinel || !bookCollection.moreBooks) return; if (!sentinel || !bookCollection.moreBooks) return;
const target = sentinel;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
async (entries) => { async (entries) => {
if (entries[0].isIntersecting && !bookCollection.loading) { if (!entries[0].isIntersecting) return;
await bookCollection.loadMoreBooks(); await bookCollection.loadMoreBooks();
// A page that does not push the sentinel back out of the prefetch zone
// produces no further intersection change, so the observer would sit
// idle until the next scroll. Re-observing asks for a fresh reading.
if (bookCollection.moreBooks) {
observer.unobserve(target);
observer.observe(target);
} }
}, },
{ {
root: scrollContainer, root: scrollContainer,
rootMargin: '0px 0px 200px 0px' // One viewport of lead time: the next page is requested a full screen
// before the reader reaches the end of the current one.
rootMargin: '0px 0px 100% 0px'
} }
); );
observer.observe(sentinel); observer.observe(target);
return () => { return () => {
observer.disconnect(); observer.disconnect();
@@ -110,7 +122,7 @@
{#if bookCollection.moreBooks} {#if bookCollection.moreBooks}
<div bind:this={sentinel}> <div bind:this={sentinel}>
{#if bookCollection.loading} {#if bookCollection.loadingMore}
<Spinner /> <Spinner />
{/if} {/if}
</div> </div>
@@ -28,6 +28,8 @@ export class BookCollectionState {
public moreBooks = $state(false); public moreBooks = $state(false);
private currentBookPage = $state(1); private currentBookPage = $state(1);
public loading = $state(false); public loading = $state(false);
/** A page append, as opposed to `loading`, which replaces the whole list. */
public loadingMore = $state(false);
/** /**
* Whether the reader has loaded past the first page. Anything that refetches * Whether the reader has loaded past the first page. Anything that refetches
@@ -165,11 +167,21 @@ export class BookCollectionState {
}); });
this.books = [...result.items]; this.books = [...result.items];
this.moreBooks = result.total > result.items.length;
this.currentBookPage = 1; this.currentBookPage = 1;
this.loading = false; this.loading = false;
} }
/**
* Prefetching means the trigger can fire again while a page is still in
* flight, so the guard is here rather than in the observer — every caller
* gets it, and a second call is dropped instead of duplicating a page.
*/
async loadMoreBooks() { async loadMoreBooks() {
if (this.loadingMore || !this.moreBooks) return;
this.loadingMore = true;
try {
const result = await this.ops.listBooks({ const result = await this.ops.listBooks({
currentPage: this.currentBookPage + 1, currentPage: this.currentBookPage + 1,
pageSize: 50, pageSize: 50,
@@ -179,8 +191,13 @@ export class BookCollectionState {
}); });
this.books = [...this.books, ...result.items]; this.books = [...this.books, ...result.items];
this.moreBooks = result.total > this.books.length; // An empty page means the count we were given was stale; stop asking
// rather than loop on a page that never grows the list.
this.moreBooks = result.items.length > 0 && result.total > this.books.length;
this.currentBookPage++; this.currentBookPage++;
} finally {
this.loadingMore = false;
}
} }
updateBooks(books: PaginatedResponse<Book>) { updateBooks(books: PaginatedResponse<Book>) {