diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 4c72d0d78..0fc72d5eb 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -50,6 +50,10 @@ export default { return { routeFullPath: null, initialized: false, + paginationMode: 'offset', + nextCursor: null, + isCountDeferred: false, + pageCursors: {}, bookshelfHeight: 0, bookshelfWidth: 0, shelvesPerPage: 0, @@ -327,6 +331,36 @@ export default { 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) { const startIndex = page * this.booksPerFetch @@ -334,11 +368,11 @@ export default { if (!this.initialized) { this.currentSFQueryString = this.buildSearchParams() + this.resetPaginationState() } let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName - const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' - const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete,share` + const fullQueryString = this.getFetchQueryString(page) const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => { console.error('failed to fetch items', error) @@ -352,6 +386,18 @@ export default { return } 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) { this.initialized = true this.totalEntities = payload.total @@ -371,7 +417,17 @@ export default { } }, 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] }, showHideBookPlaceholder(index, show) { @@ -413,7 +469,15 @@ export default { clearTimeout(this.postScrollTimeout) const firstPage = Math.floor(firstEntityIndex / 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)) .catch((error) => console.error('Failed to load page', error)) @@ -426,6 +490,7 @@ export default { } this.destroyEntityComponents() this.pagesLoaded = {} + this.resetPaginationState() this.entities = [] this.totalShelves = 0 this.totalEntities = 0 diff --git a/client/cypress/tests/components/app/LazyBookshelf.cy.js b/client/cypress/tests/components/app/LazyBookshelf.cy.js new file mode 100644 index 000000000..01c71101e --- /dev/null +++ b/client/cypress/tests/components/app/LazyBookshelf.cy.js @@ -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') + }) + }) +})