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) {