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,34 +391,44 @@ export default {
return return
} }
if (payload) { if (payload) {
this.paginationMode = payload.paginationMode || this.paginationMode try {
this.nextCursor = payload.nextCursor || null const results = Array.isArray(payload.results) ? payload.results : null
this.isCountDeferred = !!payload.isCountDeferred if (!results) {
throw new Error('Invalid browse payload: missing results array')
if (this.paginationMode === 'keyset') {
if (this.nextCursor) {
this.pageCursors[page + 1] = this.nextCursor
} else {
delete this.pageCursors[page + 1]
} }
}
if (!this.initialized) { this.paginationMode = payload.paginationMode || this.paginationMode
this.initialized = true this.nextCursor = payload.nextCursor || null
this.totalEntities = payload.total this.isCountDeferred = !!payload.isCountDeferred
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
this.entities = new Array(this.totalEntities)
}
for (let i = 0; i < payload.results.length; i++) { if (this.paginationMode === 'keyset') {
const index = i + startIndex if (this.nextCursor) {
this.entities[index] = payload.results[i] this.pageCursors[page + 1] = this.nextCursor
if (this.entityComponentRefs[index]) { } else {
this.entityComponentRefs[index].setEntity(this.entities[index]) delete this.pageCursors[page + 1]
}
} }
}
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) if (!this.initialized) {
this.initialized = true
this.totalEntities = payload.total
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
this.entities = new Array(this.totalEntities)
}
for (let i = 0; i < results.length; i++) {
const index = i + startIndex
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) { 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,7 +1,88 @@
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 = () => ({
getters: {
'user/getIsAdminOrUp': true,
'libraries/getCurrentLibraryMediaType': 'book',
'libraries/getCurrentLibraryName': 'Library',
'user/getUserSetting': (key) => {
const settings = {
orderBy: 'media.metadata.title',
orderDesc: false,
filterBy: 'all',
collapseSeries: false,
collapseBookSeries: false,
seriesSortBy: 'name',
seriesSortDesc: false,
seriesFilterBy: 'all',
authorSortBy: 'name',
authorSortDesc: false
}
return settings[key]
},
'libraries/getBookCoverAspectRatio': 1.6,
'getServerSetting': () => false,
getBookshelfView: 'grid',
'user/getSizeMultiplier': 1,
'tasks/getRunningLibraryScanTask': () => null
},
state: {
libraries: {
currentLibraryId: 'lib-1'
},
globals: {
selectedMediaItems: []
},
streamLibraryItem: null,
lastBookshelfScrollData: {}
},
dispatch: cy.stub().resolves(),
commit: cy.stub()
})
const mountBookshelf = (getStub, data = {}) => {
return cy.mount(LazyBookshelf, {
mocks: {
$axios: { $get: getStub },
$store: createStore(),
$eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} },
$root: { socket: { on: () => {}, off: () => {} } },
$route: { query: {} },
$router: { push: cy.stub() },
$toast: { error: cy.stub() },
$getString: (key) => key,
$encode: encodeURIComponent,
$decode: decodeURIComponent
},
methods: {
async setCardSize() {
this.cardWidth = 120
this.cardHeight = 180
this.coverHeight = 160
},
mountEntityCard() {},
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 {
initialPagesToLoad: 2,
...data
}
}
})
}
it('uses the server cursor for mounted keyset follow-up chunks', () => {
const requestUrls = [] const requestUrls = []
const getStub = cy.stub().callsFake((url) => { const getStub = cy.stub().callsFake((url) => {
requestUrls.push(url) requestUrls.push(url)
@ -25,84 +106,47 @@ describe('LazyBookshelf', () => {
}) })
}) })
const store = { mountBookshelf(getStub).then(({ wrapper }) => {
getters: { cy.wrap(getStub).should('have.been.calledTwice').then(() => {
'user/getIsAdminOrUp': true, expect(wrapper.vm.pagesLoaded[1]).to.be.ok
'libraries/getCurrentLibraryMediaType': 'book', expect(wrapper.vm.pageCursors[1]).to.equal('cursor-1')
'libraries/getCurrentLibraryName': 'Library', expect(wrapper.vm.paginationMode).to.equal('keyset')
'user/getUserSetting': (key) => { expect(wrapper.vm.isProgressiveLoading).to.equal(false)
const settings = { expect(requestUrls).to.have.length(2)
orderBy: 'media.metadata.title', expect(requestUrls[0]).to.include('page=0')
orderDesc: false, expect(requestUrls[0]).to.not.include('cursor=')
filterBy: 'all', expect(requestUrls[1]).to.include('cursor=cursor-1')
collapseSeries: false, expect(requestUrls[1]).to.not.include('page=1')
collapseBookSeries: false, })
seriesSortBy: 'name', })
seriesSortDesc: false, })
seriesFilterBy: 'all',
authorSortBy: 'name',
authorSortDesc: false
}
return settings[key]
},
'libraries/getBookCoverAspectRatio': 1.6,
'getServerSetting': () => false,
getBookshelfView: 'grid',
'user/getSizeMultiplier': 1,
'tasks/getRunningLibraryScanTask': () => null
},
state: {
libraries: {
currentLibraryId: 'lib-1'
},
globals: {
selectedMediaItems: []
},
streamLibraryItem: null,
lastBookshelfScrollData: {}
},
dispatch: cy.stub().resolves(),
commit: cy.stub()
}
cy.mount(LazyBookshelf, { it('clears progressive loading state when a keyset follow-up chunk fails', () => {
mocks: { const getStub = cy.stub()
$axios: { $get: getStub },
$store: store,
$eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} },
$root: { socket: { on: () => {}, off: () => {} } },
$route: { query: {} },
$router: { push: cy.stub() },
$toast: { error: cy.stub() },
$getString: (key) => key,
$encode: encodeURIComponent,
$decode: decodeURIComponent
},
methods: {
async setCardSize() {
this.cardWidth = 120
this.cardHeight = 180
this.coverHeight = 160
},
mountEntityCard() {},
initListeners() {}
},
data() {
return {
booksPerFetch: 1,
entitiesPerShelf: 1,
shelvesPerPage: 1
}
}
}).then(async ({ wrapper }) => {
await wrapper.vm.fetchEntites(0)
await wrapper.vm.fetchEntites(1)
expect(requestUrls).to.have.length(2) getStub.onFirstCall().resolves({
expect(requestUrls[0]).to.include('page=0') results: [{ id: 'item-1', mediaType: 'book', media: { metadata: { title: 'Alpha' } } }],
expect(requestUrls[0]).to.not.include('cursor=') total: 4,
expect(requestUrls[1]).to.include('cursor=cursor-1') nextCursor: 'cursor-1',
expect(requestUrls[1]).to.not.include('page=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)
})
}) })
}) })
}) })