From 0220f2d970c9cb2b48a87ee73ba198735d946866 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 17 Aug 2026 13:48:40 -0400 Subject: [PATCH] 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. --- .../lib/components/view/book-browser.svelte | 24 +++++++++--- .../src/lib/state/bookCollection.svelte.ts | 37 ++++++++++++++----- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/components/view/book-browser.svelte b/frontend/src/lib/components/view/book-browser.svelte index 3658ab0..4866dba 100644 --- a/frontend/src/lib/components/view/book-browser.svelte +++ b/frontend/src/lib/components/view/book-browser.svelte @@ -29,19 +29,31 @@ $effect(() => { if (!sentinel || !bookCollection.moreBooks) return; + const target = sentinel; + const observer = new IntersectionObserver( async (entries) => { - if (entries[0].isIntersecting && !bookCollection.loading) { - await bookCollection.loadMoreBooks(); + if (!entries[0].isIntersecting) return; + + 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, - 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 () => { observer.disconnect(); @@ -110,7 +122,7 @@ {#if bookCollection.moreBooks}
- {#if bookCollection.loading} + {#if bookCollection.loadingMore} {/if}
diff --git a/frontend/src/lib/state/bookCollection.svelte.ts b/frontend/src/lib/state/bookCollection.svelte.ts index 5fd0e6e..fb05cc3 100644 --- a/frontend/src/lib/state/bookCollection.svelte.ts +++ b/frontend/src/lib/state/bookCollection.svelte.ts @@ -28,6 +28,8 @@ export class BookCollectionState { public moreBooks = $state(false); private currentBookPage = $state(1); 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 @@ -165,22 +167,37 @@ export class BookCollectionState { }); this.books = [...result.items]; + this.moreBooks = result.total > result.items.length; this.currentBookPage = 1; 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() { - const result = await this.ops.listBooks({ - currentPage: this.currentBookPage + 1, - pageSize: 50, - sortOrder: this.sortOrder, - orderBy: this.orderBy, - ...this.filters - }); + if (this.loadingMore || !this.moreBooks) return; - this.books = [...this.books, ...result.items]; - this.moreBooks = result.total > this.books.length; - this.currentBookPage++; + this.loadingMore = true; + try { + const result = await this.ops.listBooks({ + currentPage: this.currentBookPage + 1, + pageSize: 50, + sortOrder: this.sortOrder, + orderBy: this.orderBy, + ...this.filters + }); + + this.books = [...this.books, ...result.items]; + // 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++; + } finally { + this.loadingMore = false; + } } updateBooks(books: PaginatedResponse) {