From f6fe1484a91c68a46b505464838ce5c90b14ba2b Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 18 May 2026 21:30:36 +0300 Subject: [PATCH] fix(ui): in-page browse virtual lists and cover load priority (#783) * fix(ui): in-page browse virtual lists and cover load priority Align virtualizer scrollMargin with the in-page scroll viewport so artist/album rows do not vanish on deep scroll after #731. Resolve CachedImage IntersectionObserver root to the nearest scrolling pane so network fetch slots favor visible covers; throttle Artists infinite scroll to one page per visibleCount update. * chore(release): CHANGELOG and credits for PR #783 --- CHANGELOG.md | 9 ++++ src/components/CachedImage.tsx | 19 +++++--- src/components/VirtualCardGrid.tsx | 12 ++--- src/components/artists/ArtistAvatars.tsx | 3 ++ src/config/settingsCredits.ts | 1 + src/hooks/useArtistsInfiniteScroll.ts | 13 ++++-- src/pages/Artists.tsx | 4 +- src/pages/Composers.tsx | 2 +- .../ui/resolveIntersectionScrollRoot.test.ts | 44 +++++++++++++++++++ src/utils/ui/resolveIntersectionScrollRoot.ts | 25 +++++++++++ 10 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 src/utils/ui/resolveIntersectionScrollRoot.test.ts create mode 100644 src/utils/ui/resolveIntersectionScrollRoot.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cbaf7a..b2751a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Fixed + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** + +* **In-page browse:** virtual artist/album/composer grids and lists no longer lose all rows after deep scroll — `scrollMargin` now targets the in-page overlay viewport, not the locked main route scrollport. +* **Cover art on browse pages:** `CachedImage` priority scoring follows the real scrolling pane so visible thumbnails win network fetch slots; Artists infinite scroll loads one page per batch instead of re-entrantly queueing many pages during a fast fling. + + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src/components/CachedImage.tsx b/src/components/CachedImage.tsx index b4d8a339..48d48eed 100644 --- a/src/components/CachedImage.tsx +++ b/src/components/CachedImage.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { acquireUrl, getCachedBlob, releaseUrl, subscribeCoverUpgraded } from '../utils/imageCache'; +import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot'; interface CachedImageProps extends React.ImgHTMLAttributes { src: string; @@ -11,11 +11,16 @@ interface CachedImageProps extends React.ImgHTMLAttributes { */ fetchQueueBias?: number; /** - * How far beyond the app scroll viewport `IntersectionObserver` expands the root. + * How far beyond the scroll viewport `IntersectionObserver` expands the root. * Larger = priority / slot ordering updates while the row is still off-screen → less * empty flash when it hits the viewport. CSS margin syntax (`440px`, `10% 0`, …). */ observeRootMargin?: string; + /** + * Optional explicit IO root (e.g. in-page browse viewport id). When omitted, + * the nearest scrolling ancestor is used so priority tracks the visible pane. + */ + observeScrollRootId?: string; } /** Search UI: load artist avatars before album covers when many requests compete. */ @@ -142,6 +147,7 @@ export default function CachedImage({ cacheKey, fetchQueueBias = 0, observeRootMargin = DEFAULT_CACHED_IMAGE_PREPARE_MARGIN, + observeScrollRootId, style, onLoad, onError, @@ -164,9 +170,10 @@ export default function CachedImage({ const el = imgRef.current; if (!el) return; const root = - typeof document !== 'undefined' - ? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as Element | null) - : null; + (observeScrollRootId + ? (document.getElementById(observeScrollRootId) as Element | null) + : null) + ?? resolveIntersectionScrollRoot(el); const updateFromEntry = (entry: IntersectionObserverEntry) => { if (entry.isIntersecting) { const r = entry.boundingClientRect; @@ -191,7 +198,7 @@ export default function CachedImage({ ); observer.observe(el); return () => observer.disconnect(); - }, [observeRootMargin]); + }, [observeRootMargin, observeScrollRootId]); // Same as Hero/PlayerBar: show the salted fetch URL while IndexedDB/network resolves, // then swap to the shared blob URL — avoids an with no src and opacity stuck at 0. diff --git a/src/components/VirtualCardGrid.tsx b/src/components/VirtualCardGrid.tsx index 3e0407d4..458a6ff0 100644 --- a/src/components/VirtualCardGrid.tsx +++ b/src/components/VirtualCardGrid.tsx @@ -59,14 +59,10 @@ export function VirtualCardGrid({ return document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null; }, [scrollRootId]); - const scrollMargin = useVirtualizerScrollMargin( - wrapRef, - () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), - { - active: !disableVirtualization, - deps: [layoutSignal, virtualRowCount], - }, - ); + const scrollMargin = useVirtualizerScrollMargin(wrapRef, getScrollElement, { + active: !disableVirtualization, + deps: [layoutSignal, virtualRowCount, scrollRootId], + }); const virtualizer = useVirtualizer({ count: disableVirtualization ? 0 : virtualRowCount, diff --git a/src/components/artists/ArtistAvatars.tsx b/src/components/artists/ArtistAvatars.tsx index db2d93d0..60bed26f 100644 --- a/src/components/artists/ArtistAvatars.tsx +++ b/src/components/artists/ArtistAvatars.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; import type { SubsonicArtist } from '../../api/subsonicTypes'; import CachedImage from '../CachedImage'; +import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; import { nameColor, nameInitial } from '../../utils/componentHelpers/artistsHelpers'; interface AvatarProps { @@ -31,6 +32,7 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) { src={coverSrc} cacheKey={coverKey} alt={artist.name} + observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID} /> ); @@ -64,6 +66,7 @@ export function ArtistRowAvatar({ artist, showImages }: AvatarProps) { src={coverSrc} cacheKey={coverKey} alt={artist.name} + observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} /> diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 7f3af979..edc31709 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -119,6 +119,7 @@ const CONTRIBUTOR_ENTRIES = [ 'M4A playback: fix AtomIterator overread in patched isomp4 demuxer — probe gate for ranged tail prefetch, zero-hole fallback detection (PR #757)', 'Multi-server: Lucky Mix and Now Playing keep browsed server; queue metadata via apiForServer (PR #768)', 'Linux: session GDK/WebKit mitigations, in-page library scroll, Wayland text presets (PR #731)', + 'In-page browse: virtual list scrollMargin + CachedImage load priority; Artists infinite-scroll batching (PR #783)', ], }, { diff --git a/src/hooks/useArtistsInfiniteScroll.ts b/src/hooks/useArtistsInfiniteScroll.ts index 0c6e0088..c348f4ce 100644 --- a/src/hooks/useArtistsInfiniteScroll.ts +++ b/src/hooks/useArtistsInfiniteScroll.ts @@ -40,16 +40,23 @@ export function useArtistsInfiniteScroll({ const [loadingMore, setLoadingMore] = useState(false); const loadMoreRef = useRef<() => void>(() => {}); const observerInst = useRef(null); + /** Blocks overlapping sentinel callbacks until `visibleCount` commits. */ + const loadPendingRef = useRef(false); const loadMore = useCallback(() => { - if (loadingMore) return; + if (loadPendingRef.current) return; + loadPendingRef.current = true; setLoadingMore(true); setVisibleCount(prev => prev + pageSize); - setTimeout(() => setLoadingMore(false), 100); - }, [loadingMore, pageSize]); + }, [pageSize]); loadMoreRef.current = loadMore; + useEffect(() => { + loadPendingRef.current = false; + setLoadingMore(false); + }, [visibleCount]); + useEffect(() => { setVisibleCount(pageSize); // resetDeps is intentionally spread into the dep array. diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index f2e0ec46..8e0f52ec 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -126,7 +126,7 @@ export default function Artists() { const artistGridScrollMargin = useVirtualizerScrollMargin( artistGridMeasureRef, - () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + getInpageScrollElement, { active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid', deps: [artistVirtualRowCount, artistGridCols], @@ -159,7 +159,7 @@ export default function Artists() { const artistListWrapRef = useRef(null); const artistListScrollMargin = useVirtualizerScrollMargin( artistListWrapRef, - () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + getInpageScrollElement, { active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list', deps: [artistListFlatRows.length], diff --git a/src/pages/Composers.tsx b/src/pages/Composers.tsx index 30684672..17fb76c8 100644 --- a/src/pages/Composers.tsx +++ b/src/pages/Composers.tsx @@ -215,7 +215,7 @@ export default function Composers() { const composerListWrapRef = useRef(null); const composerListScrollMargin = useVirtualizerScrollMargin( composerListWrapRef, - () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID), + getInpageScrollElement, { active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list', deps: [composerListFlatRows.length], diff --git a/src/utils/ui/resolveIntersectionScrollRoot.test.ts b/src/utils/ui/resolveIntersectionScrollRoot.test.ts new file mode 100644 index 00000000..450e33d2 --- /dev/null +++ b/src/utils/ui/resolveIntersectionScrollRoot.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { describe, expect, it, beforeEach } from 'vitest'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; +import { resolveIntersectionScrollRoot } from './resolveIntersectionScrollRoot'; + +describe('resolveIntersectionScrollRoot', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('prefers the nearest scrolling ancestor', () => { + const outer = document.createElement('div'); + const scroller = document.createElement('div'); + const img = document.createElement('img'); + Object.defineProperty(scroller, 'scrollHeight', { value: 2000, configurable: true }); + Object.defineProperty(scroller, 'clientHeight', { value: 400, configurable: true }); + scroller.style.overflowY = 'auto'; + outer.appendChild(scroller); + scroller.appendChild(img); + document.body.appendChild(outer); + + expect(resolveIntersectionScrollRoot(img)).toBe(scroller); + }); + + it('falls back to mainstage in-page viewport class', () => { + const inpage = document.createElement('div'); + inpage.className = 'mainstage-inpage-scroll__viewport'; + const img = document.createElement('img'); + inpage.appendChild(img); + document.body.appendChild(inpage); + + expect(resolveIntersectionScrollRoot(img)).toBe(inpage); + }); + + it('falls back to app main scroll viewport id', () => { + const main = document.createElement('div'); + main.id = APP_MAIN_SCROLL_VIEWPORT_ID; + const img = document.createElement('img'); + main.appendChild(img); + document.body.appendChild(main); + + expect(resolveIntersectionScrollRoot(img)).toBe(main); + }); +}); diff --git a/src/utils/ui/resolveIntersectionScrollRoot.ts b/src/utils/ui/resolveIntersectionScrollRoot.ts new file mode 100644 index 00000000..0d17a80e --- /dev/null +++ b/src/utils/ui/resolveIntersectionScrollRoot.ts @@ -0,0 +1,25 @@ +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll'; + +/** + * Scroll container for `IntersectionObserver` priority scoring on cover art. + * Prefer the nearest scrolling ancestor (in-page browse, queue, route viewport); + * fall back to known viewport class hooks, then the main route scroll element. + */ +export function resolveIntersectionScrollRoot(node: HTMLElement): Element | null { + let parent = node.parentElement; + while (parent) { + const { overflowY } = window.getComputedStyle(parent); + if ( + (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') + && parent.scrollHeight > parent.clientHeight + 2 + ) { + return parent; + } + parent = parent.parentElement; + } + return ( + (node.closest('.mainstage-inpage-scroll__viewport') as HTMLElement | null) + ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null) + ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null) + ); +}