diff --git a/fe/src/__tests__/AudiobooksView.spec.ts b/fe/src/__tests__/AudiobooksView.spec.ts index 718836de..e4a84f22 100644 --- a/fe/src/__tests__/AudiobooksView.spec.ts +++ b/fe/src/__tests__/AudiobooksView.spec.ts @@ -796,3 +796,229 @@ describe('AudiobooksView Grouping', () => { expect(wrapper.find('.series-bottom-placard').exists()).toBe(true) }) }) + +describe('AudiobooksView list-view virtual scroll', () => { + type ScrollVm = { + viewMode: 'grid' | 'list' + showItemDetails: boolean + measuredRowHeight: number | null + visibleRange: { start: number; end: number } + totalHeight: number + getRowHeight: () => number + updateVisibleRange: () => void + onScroll: () => void + sortKey: string + sortOrder: 'asc' | 'desc' + } + + const ensureBrowserGlobals = () => { + if ( + typeof (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver === 'undefined' + ) { + ;(globalThis as unknown as Record).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + } + if (typeof (globalThis as unknown as { WebSocket?: unknown }).WebSocket === 'undefined') { + ;(globalThis as unknown as Record).WebSocket = function () { + /* noop */ + } + } + } + + const mountListView = async (count: number) => { + ensureBrowserGlobals() + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'home', component: { template: '
' } }, + { path: '/audiobooks', name: 'audiobooks', component: AudiobooksView }, + ], + }) + await router.push('/audiobooks') + await router.isReady().catch(() => {}) + + const store = useLibraryStore() + store.audiobooks = Array.from({ length: count }, (_, index) => ({ + id: index + 1, + title: `Book ${index + 1}`, + authors: [`Author ${index % 5}`], + imageUrl: `cover${index + 1}.jpg`, + files: [], + })) as unknown as import('@/types').Audiobook[] + store.fetchLibrary = vi.fn(async () => undefined) + + localStorage.setItem('listenarr.groupBy', 'books') + localStorage.setItem('listenarr.showItemDetails', 'false') + + const wrapper = mount(AudiobooksView, { + global: { + plugins: [pinia, router], + stubs: [ + 'BulkEditModal', + 'EditAudiobookModal', + 'CustomFilterModal', + 'FiltersDropdown', + 'CustomSelect', + ], + }, + }) + await new Promise((r) => setTimeout(r, 0)) + + const vm = wrapper.vm as unknown as ScrollVm + vm.viewMode = 'list' + await new Promise((r) => setTimeout(r, 0)) + return { wrapper, vm, store } + } + + beforeEach(() => { + localStorage.clear() + }) + + it('uses a fixed list row height and ignores a stale grid measuredRowHeight (no oversized scroll area)', async () => { + const { vm } = await mountListView(50) + + // Simulate an inflated grid measurement leaking into list view via the shared ref. + vm.measuredRowHeight = 240 + + expect(vm.viewMode).toBe('list') + // Must be the fixed list constant, not the leaked 240px grid measurement. + expect(vm.getRowHeight()).toBe(80) + }) + + it('uses a taller fixed row height when show-details is enabled', async () => { + const { vm } = await mountListView(10) + + vm.showItemDetails = true + await new Promise((r) => setTimeout(r, 0)) + + expect(vm.getRowHeight()).toBe(120) + }) + + it('reserves space for the list header in totalHeight so the last row stays reachable', async () => { + const { vm } = await mountListView(50) + + // n rows (80px) plus the always-present column header (40px). Without + // reserving the header height the last row sits below the scrollable area + // and cannot be fully scrolled into view. + expect(vm.totalHeight).toBe(80 * 50 + 40) + }) + + it('keeps the list scroll geometry stable when the sort order changes (sort-then-scroll)', async () => { + const { vm } = await mountListView(50) + + // Simulate a tall row having been sampled, then the user re-sorts (e.g. Author + // Z->A). The reorder must not let that stale sample — or the reshuffle of which + // row renders first — change the fixed list row height or the scroll area; that + // interaction was what locked up the page on the next scroll. + vm.measuredRowHeight = 240 + vm.sortKey = 'author-last' + vm.sortOrder = 'desc' + await new Promise((r) => setTimeout(r, 0)) + + expect(vm.sortOrder).toBe('desc') + expect(vm.getRowHeight()).toBe(80) + expect(vm.totalHeight).toBe(80 * 50 + 40) + }) + + it('does not reassign visibleRange when the computed range is unchanged', async () => { + const { vm } = await mountListView(50) + + vm.updateVisibleRange() + const firstRange = vm.visibleRange + vm.updateVisibleRange() + const secondRange = vm.visibleRange + + // Stable identity → no needless watcher fires / re-renders on every scroll tick. + expect(secondRange).toBe(firstRange) + }) + + it('coalesces rapid scroll events into a single animation frame', async () => { + const { vm } = await mountListView(50) + + const rafCallbacks: FrameRequestCallback[] = [] + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + try { + vm.onScroll() + vm.onScroll() + vm.onScroll() + // Three scroll events, one scheduled frame. + expect(rafSpy).toHaveBeenCalledTimes(1) + + // Flushing the frame allows the next scroll to schedule again. + rafCallbacks[0]?.(0) + vm.onScroll() + expect(rafSpy).toHaveBeenCalledTimes(2) + } finally { + rafSpy.mockRestore() + } + }) + + it('widens the visible slice to the rows scrolled into view', async () => { + const { wrapper, vm } = await mountListView(50) + const el = wrapper.find('.audiobooks-scroll-container').element as HTMLElement + // jsdom has no layout, so force the scroll geometry the math reads. + Object.defineProperty(el, 'clientHeight', { value: 800, configurable: true }) + Object.defineProperty(el, 'scrollTop', { value: 800, configurable: true }) + + vm.updateVisibleRange() + + // scrollTop 800 / 80px rows = row 10, ±BUFFER_ROWS(2), 10 viewport rows. + expect(vm.visibleRange).toEqual({ start: 8, end: 22 }) + }) + + it('applies the new range only when the scheduled animation frame runs', async () => { + const { wrapper, vm } = await mountListView(50) + const el = wrapper.find('.audiobooks-scroll-container').element as HTMLElement + Object.defineProperty(el, 'clientHeight', { value: 800, configurable: true }) + Object.defineProperty(el, 'scrollTop', { value: 800, configurable: true }) + + const frames: FrameRequestCallback[] = [] + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + frames.push(cb) + return frames.length + }) + + try { + const before = { ...vm.visibleRange } + vm.onScroll() + // Deferred: nothing changes until the frame fires. + expect(vm.visibleRange).toEqual(before) + + frames[0]?.(0) + expect(vm.visibleRange).toEqual({ start: 8, end: 22 }) + } finally { + rafSpy.mockRestore() + } + }) + + it('cancels a pending scroll animation frame on unmount', async () => { + const { wrapper, vm } = await mountListView(50) + + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation(() => 123 as unknown as number) + const cancelSpy = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}) + + try { + vm.onScroll() // schedules frame 123 (mock never runs it) + wrapper.unmount() + expect(cancelSpy).toHaveBeenCalledWith(123) + } finally { + rafSpy.mockRestore() + cancelSpy.mockRestore() + } + }) +}) diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index f7b7fa4e..d4822edb 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -404,7 +404,7 @@ v-else ref="scrollContainer" :class="['audiobooks-scroll-container', { 'has-selection': selectedCount > 0 }]" - @scroll="updateVisibleRange" + @scroll="onScroll" >
0) { return measuredRowHeight.value } - if (viewMode.value === 'grid') { - if (scrollContainer.value && ITEMS_PER_ROW.value > 0) { - const contentWidth = Math.max(0, scrollContainer.value.clientWidth - 40) - const cardWidth = Math.max( - 0, - Math.floor((contentWidth - GRID_GAP * (ITEMS_PER_ROW.value - 1)) / ITEMS_PER_ROW.value), - ) + if (scrollContainer.value && ITEMS_PER_ROW.value > 0) { + const contentWidth = Math.max(0, scrollContainer.value.clientWidth - 40) + const cardWidth = Math.max( + 0, + Math.floor((contentWidth - GRID_GAP * (ITEMS_PER_ROW.value - 1)) / ITEMS_PER_ROW.value), + ) - if (cardWidth > 0) { - return cardWidth + GRID_GAP + (showItemDetails.value ? GRID_DETAILS_EXTRA_HEIGHT : 0) - } + if (cardWidth > 0) { + return cardWidth + GRID_GAP + (showItemDetails.value ? GRID_DETAILS_EXTRA_HEIGHT : 0) } - - return GRID_ROW_HEIGHT_FALLBACK + (showItemDetails.value ? GRID_DETAILS_EXTRA_HEIGHT : 0) } - return LIST_ROW_HEIGHT + + return GRID_ROW_HEIGHT_FALLBACK + (showItemDetails.value ? GRID_DETAILS_EXTRA_HEIGHT : 0) } function syncMeasuredRowHeight() { @@ -1777,7 +1785,25 @@ const updateVisibleRange = () => { const startIndex = Math.max(0, startRow * ITEMS_PER_ROW.value) const endIndex = Math.min(endRow * ITEMS_PER_ROW.value, audiobooks.value.length) - visibleRange.value = { start: startIndex, end: endIndex } + // Only publish a new range when it actually changes. Assigning a fresh object + // on every scroll tick needlessly re-fires the visibleRange watcher and forces + // re-renders, which (together with re-measuring) caused the scroll-time freeze. + const current = visibleRange.value + if (current.start !== startIndex || current.end !== endIndex) { + visibleRange.value = { start: startIndex, end: endIndex } + } +} + +// Coalesce scroll events into one update per animation frame. updateVisibleRange +// reads layout, so running it synchronously on every scroll event caused layout +// thrash and stalls during fast flinging. +let scrollRafId: number | null = null +function onScroll() { + if (scrollRafId !== null) return + scrollRafId = requestAnimationFrame(() => { + scrollRafId = null + updateVisibleRange() + }) } // Padding for offset positioning @@ -1786,10 +1812,17 @@ const topPadding = computed(() => { return firstVisibleRow * getRowHeight() }) -// Total scroll height so the container scrollbar reflects the full list +// Total scroll height so the container scrollbar reflects the full list. +// The list view renders an always-present column header inside the scrolled +// area, so reserve its height — otherwise the last row is pushed below the +// scrollable region and can't be fully scrolled into view. const totalHeight = computed(() => { const totalRows = Math.ceil(audiobooks.value.length / ITEMS_PER_ROW.value) - return totalRows * getRowHeight() + let height = totalRows * getRowHeight() + if (viewMode.value === 'list' && audiobooks.value.length > 0) { + height += LIST_HEADER_HEIGHT + } + return height }) const deleting = ref(false) @@ -1875,11 +1908,14 @@ async function initializeVirtualScroller() { stopVisibleRangeWatch = watch( () => visibleRange.value, async () => { + // List rows are fixed-height and never need measuring. For grid, measure + // once when a row first renders; re-measuring on every range change (i.e. + // on every scroll) is what created the scroll → measure → resize → scroll + // feedback loop that made the page unresponsive. + if (viewMode.value !== 'grid') return + if (measuredRowHeight.value !== null) return await nextTick() - if (syncMeasuredRowHeight()) { - updateVisibleRange() - await nextTick() - } + if (syncMeasuredRowHeight()) updateVisibleRange() }, ) } @@ -1934,6 +1970,13 @@ onMounted(async () => { }) onUnmounted(() => { + if (scrollRafId !== null) { + try { + cancelAnimationFrame(scrollRafId) + } catch {} + scrollRafId = null + } + try { resizeObserver?.disconnect() } catch {} @@ -3794,6 +3837,17 @@ defineExpose({ transform 0.12s; border-bottom: 1px solid rgba(255, 255, 255, 0.03); cursor: pointer; + /* Fixed height keeps the virtual scroller's row math deterministic; the value + must match LIST_ROW_HEIGHT in the script. overflow:hidden clips any content + that would otherwise grow a row and desync the scroll height. */ + box-sizing: border-box; + height: 80px; + overflow: hidden; +} + +/* Taller fixed row when extra details are shown — must match LIST_ROW_HEIGHT_DETAILS. */ +.audiobook-list-item.show-details { + height: 120px; } .audiobook-list-item:hover { @@ -3856,7 +3910,8 @@ defineExpose({ justify-self: end; } -/* Header row to mimic table columns */ +/* Header row to mimic table columns. Fixed height must match LIST_HEADER_HEIGHT + in the script so the virtual scroller can reserve exactly this much space. */ .list-header { display: grid; grid-template-columns: 40px 64px 1fr auto 120px; @@ -3866,6 +3921,8 @@ defineExpose({ font-size: 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.04); align-items: center; + box-sizing: border-box; + height: 40px; } .list-header .col-cover { @@ -3891,14 +3948,12 @@ defineExpose({ justify-self: start; } -/* Stack badges vertically on screens 768px and below */ +/* Keep badges in a single row on narrow screens too — list rows are a fixed + height, so stacking them vertically would only get clipped. */ @media (max-width: 978px) { .list-badges { - flex-direction: column; gap: 4px; - align-items: flex-start; margin-left: 0; - margin-top: 8px; } }