feat: add cursor-aware endless scroll

This commit is contained in:
Jonathan Finley 2026-03-13 23:19:24 -04:00
parent be98c79007
commit 706f219d17
2 changed files with 177 additions and 4 deletions

View file

@ -50,6 +50,10 @@ export default {
return { return {
routeFullPath: null, routeFullPath: null,
initialized: false, initialized: false,
paginationMode: 'offset',
nextCursor: null,
isCountDeferred: false,
pageCursors: {},
bookshelfHeight: 0, bookshelfHeight: 0,
bookshelfWidth: 0, bookshelfWidth: 0,
shelvesPerPage: 0, shelvesPerPage: 0,
@ -327,6 +331,36 @@ export default {
this.lastItemIndexSelected = -1 this.lastItemIndexSelected = -1
} }
}, },
resetPaginationState() {
this.paginationMode = 'offset'
this.nextCursor = null
this.isCountDeferred = false
this.pageCursors = { 0: null }
},
getFetchQueryString(page = 0) {
const cursor = this.paginationMode === 'keyset' && page > 0 ? this.pageCursors[page] : null
const searchParams = new URLSearchParams()
if (this.currentSFQueryString) {
const currentSearchParams = new URLSearchParams(this.currentSFQueryString)
for (const [key, value] of currentSearchParams.entries()) {
searchParams.set(key, value)
}
}
searchParams.set('limit', this.booksPerFetch)
searchParams.set('pageMode', 'endless')
searchParams.set('minified', 1)
searchParams.set('include', 'rssfeed,numEpisodesIncomplete,share')
if (cursor) {
searchParams.set('cursor', cursor)
} else {
searchParams.set('page', page)
}
return `?${searchParams.toString()}`
},
async fetchEntites(page = 0) { async fetchEntites(page = 0) {
const startIndex = page * this.booksPerFetch const startIndex = page * this.booksPerFetch
@ -334,11 +368,11 @@ export default {
if (!this.initialized) { if (!this.initialized) {
this.currentSFQueryString = this.buildSearchParams() this.currentSFQueryString = this.buildSearchParams()
this.resetPaginationState()
} }
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' const fullQueryString = this.getFetchQueryString(page)
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete,share`
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
console.error('failed to fetch items', error) console.error('failed to fetch items', error)
@ -352,6 +386,18 @@ export default {
return return
} }
if (payload) { if (payload) {
this.paginationMode = payload.paginationMode || this.paginationMode
this.nextCursor = payload.nextCursor || null
this.isCountDeferred = !!payload.isCountDeferred
if (this.paginationMode === 'keyset') {
if (this.nextCursor) {
this.pageCursors[page + 1] = this.nextCursor
} else {
delete this.pageCursors[page + 1]
}
}
if (!this.initialized) { if (!this.initialized) {
this.initialized = true this.initialized = true
this.totalEntities = payload.total this.totalEntities = payload.total
@ -371,7 +417,17 @@ export default {
} }
}, },
loadPage(page) { loadPage(page) {
if (!this.pagesLoaded[page]) this.pagesLoaded[page] = this.fetchEntites(page) if (this.pagesLoaded[page]) return this.pagesLoaded[page]
if (this.paginationMode === 'keyset' && page > 0) {
this.pagesLoaded[page] = this.loadPage(page - 1).then(() => {
if (!this.pageCursors[page]) return Promise.resolve()
return this.fetchEntites(page)
})
} else {
this.pagesLoaded[page] = this.fetchEntites(page)
}
return this.pagesLoaded[page] return this.pagesLoaded[page]
}, },
showHideBookPlaceholder(index, show) { showHideBookPlaceholder(index, show) {
@ -413,7 +469,15 @@ export default {
clearTimeout(this.postScrollTimeout) clearTimeout(this.postScrollTimeout)
const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch) const firstPage = Math.floor(firstEntityIndex / this.booksPerFetch)
const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch) const lastPage = Math.floor(lastEntityIndex / this.booksPerFetch)
Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
// Mount whatever is cached immediately cached cards show at once,
// uncached slots render as skeleton until data arrives
this.mountEntities(firstEntityIndex, lastEntityIndex)
// Then fetch missing pages and remount to fill in any skeleton placeholders
const visiblePageLoads = this.paginationMode === 'keyset' ? this.loadPage(firstPage).then(() => this.loadPage(lastPage)) : Promise.all([this.loadPage(firstPage), this.loadPage(lastPage)])
visiblePageLoads
.then(() => this.mountEntities(firstEntityIndex, lastEntityIndex)) .then(() => this.mountEntities(firstEntityIndex, lastEntityIndex))
.catch((error) => console.error('Failed to load page', error)) .catch((error) => console.error('Failed to load page', error))
@ -426,6 +490,7 @@ export default {
} }
this.destroyEntityComponents() this.destroyEntityComponents()
this.pagesLoaded = {} this.pagesLoaded = {}
this.resetPaginationState()
this.entities = [] this.entities = []
this.totalShelves = 0 this.totalShelves = 0
this.totalEntities = 0 this.totalEntities = 0

View file

@ -0,0 +1,108 @@
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 = {
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()
}
cy.mount(LazyBookshelf, {
mocks: {
$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)
expect(requestUrls[0]).to.include('page=0')
expect(requestUrls[0]).to.not.include('cursor=')
expect(requestUrls[1]).to.include('cursor=cursor-1')
expect(requestUrls[1]).to.not.include('page=1')
})
})
})