fix(virtual): apply scrollMargin to remaining useVirtualizer call-sites (#766)

* refactor(virtual): extract scrollMargin measurement into a reusable hook

VirtualCardGrid carried its own useLayoutEffect + ResizeObserver block for
the scroll-margin computation introduced in #764. The same pattern is
needed on every other useVirtualizer site whose wrapper sits below other
content; pull it out as useVirtualizerScrollMargin so each call-site is a
single hook invocation instead of a 20-line duplicate.

No behavior change for VirtualCardGrid — same scroll element, same deps,
same observed nodes.

* fix(virtual): apply scrollMargin to the remaining useVirtualizer call-sites

#764 fixed the symptom for VirtualCardGrid (Albums / Artists grid via the
shared component, Composers grid, "More by …" rail, etc.) but four more
useVirtualizer call-sites have the same shape — wrapper sits below a
sticky page header and TanStack would otherwise place rows starting at
the scroll-element top, unmounting rows that are still in the viewport
and at higher offsets refusing to render at all:

- Artists grid view (Artists.tsx + ArtistsGridView.tsx) — under the
  sticky title + filter row + alphabet bar (~150–250 px depending on
  alphabet wrap).
- Artists list view (Artists.tsx + ArtistsListView.tsx) — same header
  stack; flat letter/row stream means a noticeable jump on long
  libraries.
- Composers list view (Composers.tsx) — identical header stack to
  Artists.
- VirtualSongList — ~36 px sticky song-list header inside its own
  scroll container; the offset was small enough to be absorbed by
  overscan, but the pattern was the same so wire it up for consistency.

Each call-site now feeds wrapRef + scroll-element lookup into
useVirtualizerScrollMargin and forwards the value into useVirtualizer +
the translateY of each rendered row.

* docs(changelog): note PR #766 under Fixed in v1.46.0
This commit is contained in:
Frank Stellmacher
2026-05-18 01:27:38 +02:00
committed by GitHub
parent d3fc5c91fc
commit 48a69754d6
8 changed files with 125 additions and 39 deletions
+25 -1
View File
@@ -12,6 +12,7 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { useCardGridMetrics } from '../hooks/useCardGridMetrics';
import { useRemeasureGridVirtualizer } from '../hooks/useRemeasureGridVirtualizer';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import {
ALL_SENTINEL,
@@ -99,6 +100,15 @@ export default function Artists() {
Math.ceil(mainScrollViewportHeight / Math.max(1, artistGridRowHeightEst)),
);
const artistGridScrollMargin = useVirtualizerScrollMargin(
artistGridMeasureRef,
() => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
{
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'grid',
deps: [artistVirtualRowCount, artistGridCols],
},
);
const artistGridVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'grid'
@@ -107,6 +117,7 @@ export default function Artists() {
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
estimateSize: () => artistGridRowHeightEst,
overscan: artistGridOverscan,
scrollMargin: artistGridScrollMargin,
});
useRemeasureGridVirtualizer(artistGridVirtualizer, {
@@ -122,6 +133,16 @@ export default function Artists() {
Math.ceil(mainScrollViewportHeight / ARTIST_LIST_ROW_EST),
);
const artistListWrapRef = useRef<HTMLDivElement>(null);
const artistListScrollMargin = useVirtualizerScrollMargin(
artistListWrapRef,
() => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
{
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list',
deps: [artistListFlatRows.length],
},
);
const artistListVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : artistListFlatRows.length,
@@ -140,6 +161,7 @@ export default function Artists() {
return `artist:${row.artist.id}`;
},
overscan: artistListOverscan,
scrollMargin: artistListScrollMargin,
});
return (
@@ -228,7 +250,7 @@ export default function Artists() {
virtualization={
perfFlags.disableMainstageVirtualLists
? null
: { virtualizer: artistGridVirtualizer }
: { virtualizer: artistGridVirtualizer, scrollMargin: artistGridScrollMargin }
}
selectionMode={selectionMode}
selectedIds={selectedIds}
@@ -248,6 +270,8 @@ export default function Artists() {
letters={letters}
artistListFlatRows={artistListFlatRows}
artistListVirtualizer={artistListVirtualizer}
artistListWrapRef={artistListWrapRef}
artistListScrollMargin={artistListScrollMargin}
selectionMode={selectionMode}
selectedIds={selectedIds}
selectedArtists={selectedArtists}
+15 -3
View File
@@ -12,6 +12,7 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
const ALL_SENTINEL = 'ALL';
const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
@@ -185,6 +186,16 @@ export default function Composers() {
Math.ceil(mainScrollViewportHeight / COMPOSER_LIST_ROW_EST),
);
const composerListWrapRef = useRef<HTMLDivElement>(null);
const composerListScrollMargin = useVirtualizerScrollMargin(
composerListWrapRef,
() => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
{
active: !perfFlags.disableMainstageVirtualLists && viewMode === 'list',
deps: [composerListFlatRows.length],
},
);
const composerListVirtualizer = useVirtualizer({
count:
perfFlags.disableMainstageVirtualLists || viewMode !== 'list' ? 0 : composerListFlatRows.length,
@@ -202,6 +213,7 @@ export default function Composers() {
return `composer:${row.artist.id}`;
},
overscan: composerListOverscan,
scrollMargin: composerListScrollMargin,
});
if (loadError) {
@@ -337,7 +349,7 @@ export default function Composers() {
))}
</>
) : (
<div style={{ position: 'relative', width: '100%' }}>
<div ref={composerListWrapRef} style={{ position: 'relative', width: '100%' }}>
<div
style={{
height: composerListFlatRows.length === 0 ? 0 : composerListVirtualizer.getTotalSize(),
@@ -357,7 +369,7 @@ export default function Composers() {
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
transform: `translateY(${vi.start - composerListScrollMargin}px)`,
}}
>
<h3 className="letter-heading">{row.letter}</h3>
@@ -373,7 +385,7 @@ export default function Composers() {
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
transform: `translateY(${vi.start - composerListScrollMargin}px)`,
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
}}
>