album artist gets populated with artist as fallback (frontend also uses this as display name fallback for albums). added scroll position persistence when switching main views. also added frontend cache for faster switching.
12 KiB
Scroll Position Restore: Findings & Status
Goal
When switching between views (track-list, cover-grid), restore the scroll position so the user doesn't lose their place. Data is already cached via LibraryStore so re-queries aren't needed.
Architecture
- LibraryStore (
frontend/src/store/library-store.ts): Singleton that caches track/album data and stores a per-view scroll position (stored as the first visible item index, not pixel offset). - LibraryController (
frontend/src/store/controllers/library-controller.ts): ReactiveController bridging LibraryStore to Lit components. - Save mechanism: Both components listen for
visibilityChangedevents on<lit-virtualizer>. The event carries{ first, last }(indices of first/last visible items). We storefirstin the LibraryStore on every event. - Restore mechanism: On first
visibilityChangedafter mount, callscrollToIndex(savedIndex, 'start')to jump to the saved item. - Backend:
LibraryScanCompleteevent emitted from Go after library scan; frontend store listens and invalidates caches.
Key Technical Details
lit-virtualizer internals (v2.1.1)
LitVirtualizerextendsLitElementbut usescreateRenderRoot() { return this }(no shadow DOM).- The actual work is done by a
Virtualizerclass, created by thevirtualize()directive duringLitVirtualizer.render(). - The
Virtualizerinstance is stored on the host element viahostElement[virtualizerRef]. LitVirtualizer.layoutCompletedelegates tothis[virtualizerRef]?.layoutComplete— returnsundefinedif the Virtualizer hasn't been created yet.
Virtualizer layout cycle
connected()→_schedule(_updateLayout)(deferred via microtask)_updateLayout()→_updateView()(reads viewport bounds viagetBoundingClientRect) →layout.reflowIfNeeded()- Layout
_reflow()→_getActiveItems()→_updateVisibleIndices()→_sendStateChangedMessage() _handleLayoutMessage('stateChanged')→_updateDOM():_notifyVisibility()→ dispatchesvisibilityChangedevent_notifyRange()→ dispatchesrangeChangedevent_finishDOMUpdate():_positionChildren()— positions child elements_sizeHostElement()— updates the sizer element (creates scrollable area)_correctScrollError()— calls nativescrollToif there's a scroll error
Critical: visibilityChanged fires BEFORE _finishDOMUpdate. This means when the event handler runs, the sizer hasn't been updated yet and children haven't been positioned yet.
Sizer element
The virtualizer creates scrollable area using an absolutely positioned hidden div with style.transform = translate(Wpx, Hpx). This transform creates overflow that establishes scrollHeight. For scroller mode (scroller=true), this is the mechanism for scroll area.
scrollToIndex internals
scrollToIndex(index, 'start') → element(index).scrollIntoView({ block: 'start' }) → _scrollElementIntoView:
- If item is in rendered range: calls native
scrollIntoView()on the DOM element (works immediately) - If item is NOT in range: sets
this._layout.pin = options→ triggers async reflow via_triggerReflow()→Promise.resolve().then(() => this.reflowIfNeeded())
The pin-triggered reflow: _setPositionFromPin() → calculates target scroll position → _scrollError → _sendStateChangedMessage() → _updateDOM() → _finishDOMUpdate() → _sizeHostElement() + _correctScrollError() → _nativeScrollTo().
The sizer update and scrollTo happen in the same synchronous chain. If the browser hasn't laid out the sizer yet, scrollTo may be clamped to the (incorrect) current scrollHeight.
layoutComplete internals
- Lazily created: accessing
layoutCompletecreates a promise if one doesn't exist - Resolved by
_scheduleLayoutComplete()which is called from_childrenSizeChanged(ResizeObserver callback) - Uses internal double-rAF:
requestAnimationFrame(() => requestAnimationFrame(() => resolve())) - After resolution:
_resetLayoutCompleteState()nulls out the promise (next access creates a fresh one) _scheduleLayoutCompleteonly resolves if_layoutCompletePromiseis non-null AND_pendingLayoutCompleteis null
Flow layout vs Grid layout
Flow layout (track-list):
- Computes item positions as
index * delta— doesn't need cross-axis viewport width - Works immediately on first layout cycle
- First
visibilityChangedhas real item indices
Grid layout (cover-grid):
- Needs viewport width to compute number of columns (
rolumns) - Viewport width comes from
_updateView()→getBoundingClientRect(), but on first cycle the element may have zero width - When
_viewDim2 <= 0(no width),rolumns = 0,_first = -1,_last = -1 _getItemPositiondivides byrolumns— division by 0 when columns=0 producesInfinity- First
visibilityChangedis premature:first: 0, last: 0(defaults from BaseLayout constructor, since_updateVisibleIndicesreturns early when_first === -1) - Real layout happens after ResizeObserver reports viewport width → second reflow → second
visibilityChangedwith real indices
Browser frame order
- JavaScript execution (microtasks, macrotasks)
- ResizeObserver callbacks
requestAnimationFramecallbacks- Style/Layout calculation
- Paint
What Has Been Tried
Approach 1: scrollTop pixel offset with await updateComplete + double-rAF
Result: Failed for both components.
Why: await this.updateComplete only waits for the parent Lit component's render. The LitVirtualizer child element exists in the DOM but hasn't completed its own Lit render cycle — the Virtualizer instance doesn't exist yet. The double-rAF fires too early.
Approach 2: scrollTop pixel offset with await updateComplete + await layoutComplete
Result: Failed for both components.
Why: After await this.updateComplete, this.virtualizer.layoutComplete returns undefined because virtualizerRef hasn't been set yet (LitVirtualizer hasn't rendered). await undefined resolves immediately.
Approach 3: Index-based save/restore via visibilityChanged event + immediate scrollToIndex
Result: Track-list worked. Cover-grid did not.
Why track-list worked: Flow layout has real items on first visibilityChanged. scrollToIndex sets a pin, the reflow works correctly.
Why cover-grid failed: First visibilityChanged is premature (0 columns). scrollToIndex sets pin, but reflow with 0 columns produces garbage positions (division by 0).
Approach 4: visibilityChanged + scrollHeight > clientHeight guard + layoutComplete?.then + scrollToIndex
Result: Cover-grid partially worked (scrolled to correct position after one manual scroll, not on initial load). Track-list worked but with a flash.
Why cover-grid failed: visibilityChanged fires BEFORE _finishDOMUpdate updates the sizer. So scrollHeight reflects the PREVIOUS state (premature layout with scrollSize=1). The guard scrollHeight <= clientHeight was always true during the visibilityChanged handler, causing every event to be skipped. Only after a manual scroll (which triggers a fresh visibilityChanged with updated DOM state) did it work.
Why track-list flashed: layoutComplete uses internal double-rAF, so the scroll happens 2 frames after the initial render at position 0.
Approach 5: visibilityChanged + scrollHeight > clientHeight guard removed + layoutComplete?.then + scrollToIndex
Result: Cover-grid worked but with flash. Track-list worked but with flash.
Why it flashed: The double-rAF delay in layoutComplete means 2 frames render at position 0 before scrolling.
Approach 6: visibilityChanged + requestAnimationFrame + scrollToIndex (no guard for cover-grid)
Result: Track-list worked without flash. Cover-grid did not work at all.
Why track-list worked: Flow layout has real items immediately. rAF fires after sizer is set. scrollToIndex works.
Why cover-grid failed: First visibilityChanged is premature (0 columns). hasRestoredScroll set to true. rAF fires, but grid still has 0 columns → pin fails. Restore opportunity consumed.
Approach 7: visibilityChanged + last <= 0 guard for cover-grid + requestAnimationFrame + scrollToIndex
Result: Track-list worked without flash. Cover-grid did not work.
Why cover-grid failed: The last <= 0 guard correctly skips the premature event. The second visibilityChanged (real layout, last > 0) triggers the handler. hasRestoredScroll = true, schedules rAF. But in the rAF callback, scrollToIndex → pin → reflow → _sizeHostElement + _correctScrollError → scrollTo. The sizer was updated in _finishDOMUpdate (same JS execution context as the visibilityChanged), but the browser hasn't processed it into scrollHeight yet when scrollTo is called inside the pin-triggered reflow. The scrollTo is clamped to the old (small) scrollHeight.
Key insight: For cover-grid, even after waiting for the real visibilityChanged, the pin mechanism's synchronous reflow chain does _sizeHostElement + scrollTo atomically. The browser never gets a chance to process the sizer into scrollHeight between these two operations. This is why requestAnimationFrame alone isn't enough for cover-grid — the problem isn't WHEN we call scrollToIndex, it's that scrollToIndex's internal reflow always does sizer+scroll atomically.
Current State of Code
The current code has:
track-list.ts:visibilityChangedhandler withrequestAnimationFrame+scrollToIndex(works without flash)cover-grid.ts:visibilityChangedhandler withlast <= 0guard +requestAnimationFrame+scrollToIndex(does NOT work)
Untried Ideas
-
Bypass
scrollToIndexentirely for cover-grid: AfterlayoutCompleteresolves (sizer is painted), directly setel.scrollTopinstead ofscrollToIndex. This avoids the pin mechanism's atomic sizer+scroll problem. The virtualizer will react to the scroll event and re-render items at the new position. -
Use
scrollToIndexon the SECONDvisibilityChangedafter the real one: The first realvisibilityChangedtriggers_finishDOMUpdatewhich sets the sizer. After the browser paints (next frame),scrollHeightis correct. If we could delay to the nextvisibilityChanged... but there might not be one without user interaction. -
Pre-set the pin before the virtualizer initializes: If we could inject the pin into the layout before the first
_updateLayoutruns, the virtualizer would start at the correct position. But the layout's pin setter is internal. -
Use
element(index).scrollIntoView()when the item IS in range: AfterlayoutComplete, if the target item happens to be in the rendered range, nativescrollIntoViewworks. But for distant items it won't be in range. -
Two-phase for cover-grid: Use
layoutComplete?.thento wait for sizer to be painted, then setscrollTopdirectly (notscrollToIndex). Calculate pixel offset:offset = padding + Math.floor(index / columns) * (itemHeight + gap). Grid config is known:itemSize: 230px height, gap: 16px, padding: 16px. Columns can be derived from viewport width:columns = Math.floor((viewportWidth - padding*2 + gap) / (itemWidth + gap)). This is fragile but would work. -
For cover-grid, after
layoutCompleteresolves, userequestAnimationFrame+ directscrollTop:layoutCompleteensures sizer is painted →scrollHeightis correct. Then rAF +el.scrollTop = computedOffsetavoids the pin mechanism entirely. The virtualizer reacts to the scroll event. -
Hybrid approach: Track-list uses
requestAnimationFrame+scrollToIndex(works). Cover-grid useslayoutComplete?.then+ directscrollTop(avoids pin, avoids flash since we set scrollTop before paint in the rAF following layoutComplete... actually layoutComplete already used double-rAF so there would still be a flash).
File Locations
frontend/src/store/library-store.ts— LibraryStore singletonfrontend/src/store/controllers/library-controller.ts— LibraryControllerfrontend/src/components/track-list/track-list.ts— Track list componentfrontend/src/components/cover-grid/cover-grid.ts— Cover grid componentfrontend/src/events.ts— Frontend event constantsbackend/events/events.go— Backend event constantsbackend/library/library.go— Emits LibraryScanCompletefrontend/node_modules/@lit-labs/virtualizer/— Virtualizer source (v2.1.1)