Skip to content

fix(library): stabilize list-view virtual scroller (#675)#676

Open
s3ntin3l8 wants to merge 1 commit into
Listenarrs:canaryfrom
s3ntin3l8:675-list-view-scroll
Open

fix(library): stabilize list-view virtual scroller (#675)#676
s3ntin3l8 wants to merge 1 commit into
Listenarrs:canaryfrom
s3ntin3l8:675-list-view-scroll

Conversation

@s3ntin3l8

Copy link
Copy Markdown
Contributor

Closes #675

Summary

The audiobooks list view uses a hand-rolled, uniform-height virtual scroller in AudiobooksView.vue. On a library of a few hundred books it produced an oversized scroll area (large empty region below the last item), let you overscroll past both ends, left the last row partially unreachable, and froze the page ("infinite loading") on fast scrolling. Changing the sort order (toolbar or the clickable column headers) and then scrolling triggered the same freeze.

This reproduces on a clean canary checkout (the list view + scroller already ship there; it is not specific to the grouped-list-view PR #585). The sort-then-scroll variant reproduces via the toolbar sort on canary and via the clickable sort headers downstream — it is the same bug, not a separate one.

Root cause

  • Single-sample uniform height for variable rows. syncMeasuredRowHeight() measures only the first rendered .audiobook-list-item and applies that height to every row via getRowHeight()totalHeight = ceil(n) × getRowHeight(). measuredRowHeight is a single ref shared with grid view (grid rows ≈ 220–240px). An inflated/stale sample sticks → oversized scroll area. Re-sorting reshuffles which row is sampled first, re-arming the problem.
  • Unthrottled scroll handler. @scroll="updateVisibleRange" ran on every scroll event and always assigned a new visibleRange object, so the visibleRange watcher fired every tick and forced a synchronous getBoundingClientRect reflow per event — layout thrash on a fast fling.
  • Self-referential measurement loop. That watcher re-measured and re-called updateVisibleRange(); with variable row heights there is no stable fixpoint, so it oscillated (overscroll) and saturated the main thread (freeze).
  • Header excluded from scroll height. The always-present .list-header lives inside the translateY-shifted list but wasn't counted in totalHeight, pushing the last row below the scrollable region.

Changes

  • List rows are a fixed height (CSS height + box-sizing + overflow:hidden); getRowHeight() returns that constant directly for list view and no longer consults measuredRowHeight (measurement stays grid-only). A taller fixed constant is used for the "show details" mode. This makes totalHeight deterministic and kills the oversize/oscillation by construction.
  • rAF-throttle the scroll handler so updateVisibleRange runs at most once per animation frame; the pending frame is cancelled on unmount.
  • updateVisibleRange only reassigns visibleRange when the computed range actually changes (no per-tick object churn).
  • The visibleRange watcher measures grid rows once instead of on every scroll-driven range change, breaking the scroll → measure → resize → scroll loop.
  • Reserve the fixed header height in totalHeight so the last row stays fully scrollable.

Deliberate tradeoffs (reviewer note)

  • List rows are now a fixed height with overflow:hidden, so content that would exceed the row is clipped (titles already ellipsize; the show-details row uses a taller constant sized for its two detail lines).
  • On narrow screens (≤978px), badges now stay in a single row instead of stacking vertically — stacking would only be clipped under the fixed row height. Grid view is unchanged except where it also benefits from the throttle/no-churn fixes.

Tests

Adds Vitest coverage in AudiobooksView.spec.ts for: the deterministic list row-height (ignoring a stale/leaked measuredRowHeight), the taller show-details height, header-space reservation in totalHeight, sort-invariant scroll geometry, no-churn vs. applied visibleRange updates, scroll-event coalescing, and rAF cleanup on unmount. Full frontend suite, type-check, and lint pass.

Verification

Reproduced on a clean canary checkout (fast-fling + sort-then-scroll), confirmed fixed after the change: scrolling clamps at both ends, the scroll area matches the item count, the last row is fully reachable, and no freeze — including after changing sort.

The audiobooks list view used a single sampled row height (via a ref shared
with grid view) applied to every row, an unthrottled scroll handler that
reassigned visibleRange on every tick, a visibleRange watcher that re-measured
and re-ran updateVisibleRange, and a column header excluded from the scroll
height. Together these produced an oversized scroll area, overscroll past both
ends, an unreachable last row, and a freeze on fast scrolling. Changing the
sort order (toolbar or clickable headers) reshuffles which row is sampled
first, re-arming the same freeze on the next scroll.

- Give list rows a fixed CSS height and return that constant directly from
  getRowHeight() (no measurement, no grid-height leak); add a taller constant
  for the show-details mode.
- rAF-throttle the scroll handler so updateVisibleRange runs at most once per
  animation frame; cancel the pending frame on unmount.
- Only reassign visibleRange when the computed range actually changes.
- Measure grid rows once (not on every scroll-driven range change), which
  breaks the scroll -> measure -> resize -> scroll feedback loop.
- Reserve the fixed list-header height in totalHeight so the last row stays
  fully scrollable.

Adds Vitest coverage for the deterministic list row-height math, the header
space reservation, sort-invariant scroll geometry, no-churn and applied range
updates, scroll-event coalescing, and rAF cleanup on unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@s3ntin3l8 s3ntin3l8 marked this pull request as ready for review June 10, 2026 21:09
@s3ntin3l8 s3ntin3l8 requested a review from a team June 10, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Audiobooks list view: oversized scroll area, overscroll past ends, and freeze on fast scrolling

1 participant