fix: harden cursor endless-scroll loading

This commit is contained in:
Jonathan Finley 2026-03-13 23:25:28 -04:00
parent 706f219d17
commit 5c3e8559d4
2 changed files with 221 additions and 101 deletions

View file

@ -87,7 +87,12 @@ export default {
lastTimestamp: 0, lastTimestamp: 0,
postScrollTimeout: null, postScrollTimeout: null,
currFirstEntityIndex: -1, currFirstEntityIndex: -1,
currLastEntityIndex: -1 currLastEntityIndex: -1,
initialPagesToLoad: 10,
maxConcurrentRequests: 3,
preloadThreshold: 0.6,
isProgressiveLoading: false,
progressiveLoadProgress: 0
} }
}, },
watch: { watch: {
@ -386,6 +391,12 @@ export default {
return return
} }
if (payload) { if (payload) {
try {
const results = Array.isArray(payload.results) ? payload.results : null
if (!results) {
throw new Error('Invalid browse payload: missing results array')
}
this.paginationMode = payload.paginationMode || this.paginationMode this.paginationMode = payload.paginationMode || this.paginationMode
this.nextCursor = payload.nextCursor || null this.nextCursor = payload.nextCursor || null
this.isCountDeferred = !!payload.isCountDeferred this.isCountDeferred = !!payload.isCountDeferred
@ -405,15 +416,19 @@ export default {
this.entities = new Array(this.totalEntities) this.entities = new Array(this.totalEntities)
} }
for (let i = 0; i < payload.results.length; i++) { for (let i = 0; i < results.length; i++) {
const index = i + startIndex const index = i + startIndex
this.entities[index] = payload.results[i] this.entities[index] = results[i]
if (this.entityComponentRefs[index]) { if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index]) this.entityComponentRefs[index].setEntity(this.entities[index])
} }
} }
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
} catch (error) {
console.error(`Failed to process page ${page}`, error)
return null
}
} }
}, },
loadPage(page) { loadPage(page) {
@ -428,8 +443,68 @@ export default {
this.pagesLoaded[page] = this.fetchEntites(page) this.pagesLoaded[page] = this.fetchEntites(page)
} }
this.pagesLoaded[page] = this.pagesLoaded[page].catch((error) => {
delete this.pagesLoaded[page]
throw error
})
return this.pagesLoaded[page] return this.pagesLoaded[page]
}, },
async loadPagesWithConcurrency(pages, concurrency = this.maxConcurrentRequests) {
if (this.paginationMode === 'keyset') {
let completed = 0
for (const page of pages) {
try {
await this.loadPage(page)
} catch (error) {
console.error(`Failed to load page ${page}:`, error)
break
} finally {
completed++
this.progressiveLoadProgress = Math.round((completed / pages.length) * 100)
}
}
return
}
const queue = [...pages]
const workers = Array.from({ length: Math.min(concurrency, pages.length) }, async () => {
while (queue.length) {
const page = queue.shift()
try {
await this.loadPage(page)
} catch (error) {
console.error(`Failed to load page ${page}:`, error)
} finally {
const completed = pages.length - queue.length
this.progressiveLoadProgress = Math.round((completed / pages.length) * 100)
}
}
})
await Promise.all(workers)
},
async progressiveInitialLoad() {
if (this.totalEntities === 0) return
const totalPages = Math.ceil(this.totalEntities / this.booksPerFetch)
const pagesToLoad = Math.min(this.initialPagesToLoad, totalPages)
if (pagesToLoad <= 1) return
this.isProgressiveLoading = true
this.progressiveLoadProgress = 0
const additionalPages = Array.from({ length: pagesToLoad - 1 }, (_, i) => i + 1)
try {
await this.loadPagesWithConcurrency(additionalPages)
} catch (error) {
console.error('Failed progressive load', error)
} finally {
this.isProgressiveLoading = false
this.progressiveLoadProgress = 100
}
},
showHideBookPlaceholder(index, show) { showHideBookPlaceholder(index, show) {
var el = document.getElementById(`book-${index}-placeholder`) var el = document.getElementById(`book-${index}-placeholder`)
if (el) el.style.display = show ? 'flex' : 'none' if (el) el.style.display = show ? 'flex' : 'none'
@ -824,6 +899,7 @@ export default {
await this.loadPage(0) await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntities(0, lastBookIndex) this.mountEntities(0, lastBookIndex)
this.progressiveInitialLoad().catch((error) => console.error('Failed progressive load', error))
// Set last scroll position for this bookshelf page // Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) { if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {

View file

@ -1,31 +1,7 @@
import LazyBookshelf from '@/components/app/LazyBookshelf.vue' import LazyBookshelf from '@/components/app/LazyBookshelf.vue'
describe('LazyBookshelf', () => { describe('LazyBookshelf', () => {
it('uses the server cursor for keyset follow-up chunks', () => { const createStore = () => ({
const requestUrls = []
const getStub = cy.stub().callsFake((url) => {
requestUrls.push(url)
if (requestUrls.length === 1) {
return Promise.resolve({
results: [{ id: 'item-1', mediaType: 'book', media: { metadata: { title: 'Alpha' } } }],
total: 4,
nextCursor: 'cursor-1',
paginationMode: 'keyset',
isCountDeferred: true
})
}
return Promise.resolve({
results: [{ id: 'item-2', mediaType: 'book', media: { metadata: { title: 'Beta' } } }],
total: 4,
nextCursor: 'cursor-2',
paginationMode: 'keyset',
isCountDeferred: true
})
})
const store = {
getters: { getters: {
'user/getIsAdminOrUp': true, 'user/getIsAdminOrUp': true,
'libraries/getCurrentLibraryMediaType': 'book', 'libraries/getCurrentLibraryMediaType': 'book',
@ -63,12 +39,13 @@ describe('LazyBookshelf', () => {
}, },
dispatch: cy.stub().resolves(), dispatch: cy.stub().resolves(),
commit: cy.stub() commit: cy.stub()
} })
cy.mount(LazyBookshelf, { const mountBookshelf = (getStub, data = {}) => {
return cy.mount(LazyBookshelf, {
mocks: { mocks: {
$axios: { $get: getStub }, $axios: { $get: getStub },
$store: store, $store: createStore(),
$eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} }, $eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} },
$root: { socket: { on: () => {}, off: () => {} } }, $root: { socket: { on: () => {}, off: () => {} } },
$route: { query: {} }, $route: { query: {} },
@ -85,19 +62,56 @@ describe('LazyBookshelf', () => {
this.coverHeight = 160 this.coverHeight = 160
}, },
mountEntityCard() {}, mountEntityCard() {},
initListeners() {} initSizeData() {
this.bookshelfHeight = 600
this.bookshelfWidth = 600
this.entitiesPerShelf = 1
this.shelvesPerPage = 1
this.booksPerFetch = 1
this.bookshelfMarginLeft = 0
this.currentBookWidth = this.bookWidth
return false
}
}, },
data() { data() {
return { return {
booksPerFetch: 1, initialPagesToLoad: 2,
entitiesPerShelf: 1, ...data
shelvesPerPage: 1
} }
} }
}).then(async ({ wrapper }) => { })
await wrapper.vm.fetchEntites(0) }
await wrapper.vm.fetchEntites(1)
it('uses the server cursor for mounted keyset follow-up chunks', () => {
const requestUrls = []
const getStub = cy.stub().callsFake((url) => {
requestUrls.push(url)
if (requestUrls.length === 1) {
return Promise.resolve({
results: [{ id: 'item-1', mediaType: 'book', media: { metadata: { title: 'Alpha' } } }],
total: 4,
nextCursor: 'cursor-1',
paginationMode: 'keyset',
isCountDeferred: true
})
}
return Promise.resolve({
results: [{ id: 'item-2', mediaType: 'book', media: { metadata: { title: 'Beta' } } }],
total: 4,
nextCursor: 'cursor-2',
paginationMode: 'keyset',
isCountDeferred: true
})
})
mountBookshelf(getStub).then(({ wrapper }) => {
cy.wrap(getStub).should('have.been.calledTwice').then(() => {
expect(wrapper.vm.pagesLoaded[1]).to.be.ok
expect(wrapper.vm.pageCursors[1]).to.equal('cursor-1')
expect(wrapper.vm.paginationMode).to.equal('keyset')
expect(wrapper.vm.isProgressiveLoading).to.equal(false)
expect(requestUrls).to.have.length(2) expect(requestUrls).to.have.length(2)
expect(requestUrls[0]).to.include('page=0') expect(requestUrls[0]).to.include('page=0')
expect(requestUrls[0]).to.not.include('cursor=') expect(requestUrls[0]).to.not.include('cursor=')
@ -105,4 +119,34 @@ describe('LazyBookshelf', () => {
expect(requestUrls[1]).to.not.include('page=1') expect(requestUrls[1]).to.not.include('page=1')
}) })
}) })
})
it('clears progressive loading state when a keyset follow-up chunk fails', () => {
const getStub = cy.stub()
getStub.onFirstCall().resolves({
results: [{ id: 'item-1', mediaType: 'book', media: { metadata: { title: 'Alpha' } } }],
total: 4,
nextCursor: 'cursor-1',
paginationMode: 'keyset',
isCountDeferred: true
})
getStub.onSecondCall().resolves({
total: 4,
nextCursor: null,
paginationMode: 'keyset',
isCountDeferred: true
})
cy.spy(console, 'error').as('consoleError')
mountBookshelf(getStub).then(({ wrapper }) => {
cy.wrap(getStub).should('have.been.calledTwice')
cy.get('@consoleError').should('have.been.called')
cy.wrap(null).then(() => {
expect(wrapper.vm.isProgressiveLoading).to.equal(false)
expect(wrapper.vm.progressiveLoadProgress).to.equal(100)
})
})
})
}) })