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,
postScrollTimeout: null,
currFirstEntityIndex: -1,
currLastEntityIndex: -1
currLastEntityIndex: -1,
initialPagesToLoad: 10,
maxConcurrentRequests: 3,
preloadThreshold: 0.6,
isProgressiveLoading: false,
progressiveLoadProgress: 0
}
},
watch: {
@ -386,6 +391,12 @@ export default {
return
}
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.nextCursor = payload.nextCursor || null
this.isCountDeferred = !!payload.isCountDeferred
@ -405,15 +416,19 @@ export default {
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
this.entities[index] = payload.results[i]
this.entities[index] = results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
}
}
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
} catch (error) {
console.error(`Failed to process page ${page}`, error)
return null
}
}
},
loadPage(page) {
@ -428,8 +443,68 @@ export default {
this.pagesLoaded[page] = this.fetchEntites(page)
}
this.pagesLoaded[page] = this.pagesLoaded[page].catch((error) => {
delete this.pagesLoaded[page]
throw error
})
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) {
var el = document.getElementById(`book-${index}-placeholder`)
if (el) el.style.display = show ? 'flex' : 'none'
@ -824,6 +899,7 @@ export default {
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntities(0, lastBookIndex)
this.progressiveInitialLoad().catch((error) => console.error('Failed progressive load', error))
// Set last scroll position for this bookshelf page
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {

View file

@ -1,31 +1,7 @@
import LazyBookshelf from '@/components/app/LazyBookshelf.vue'
describe('LazyBookshelf', () => {
it('uses the server cursor for 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
})
})
const store = {
const createStore = () => ({
getters: {
'user/getIsAdminOrUp': true,
'libraries/getCurrentLibraryMediaType': 'book',
@ -63,12 +39,13 @@ describe('LazyBookshelf', () => {
},
dispatch: cy.stub().resolves(),
commit: cy.stub()
}
})
cy.mount(LazyBookshelf, {
const mountBookshelf = (getStub, data = {}) => {
return cy.mount(LazyBookshelf, {
mocks: {
$axios: { $get: getStub },
$store: store,
$store: createStore(),
$eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} },
$root: { socket: { on: () => {}, off: () => {} } },
$route: { query: {} },
@ -85,19 +62,56 @@ describe('LazyBookshelf', () => {
this.coverHeight = 160
},
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() {
return {
booksPerFetch: 1,
entitiesPerShelf: 1,
shelvesPerPage: 1
initialPagesToLoad: 2,
...data
}
}
}).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[0]).to.include('page=0')
expect(requestUrls[0]).to.not.include('cursor=')
@ -106,3 +120,33 @@ describe('LazyBookshelf', () => {
})
})
})
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)
})
})
})
})