fix(grid): apply scrollMargin so VirtualCardGrid renders below tall content (#764)

react-virtual computes virtual-item positions relative to the scroll
element, but the wrapper isn't at the scroll element's top — on Album
Detail the "More by …" grid sits under the entire tracklist. Without a
matching `scrollMargin`, the virtualizer thinks every row is far above
the viewport and unmounts it, so cards visibly disappear as you scroll
past long tracklists and never appear at all once the offset exceeds
the viewport height (52-track album in the report).

Measure the wrapper's Y-offset against the scroll element inside a
useLayoutEffect, pass it as `scrollMargin`, and translate each row by
`vRow.start - scrollMargin` so positions land back in wrapper-local
coordinates. ResizeObserver on the scroll element + its content keeps
the offset correct when content above grows. `getTotalSize()` already
subtracts the margin internally (virtual-core/src/index.ts:1304), so
the wrapper height stays unchanged.

Affects every page using VirtualCardGrid; the bug only surfaced on
Album Detail because every other call site puts the grid near the top
of the scroll content.
This commit is contained in:
Frank Stellmacher
2026-05-17 23:26:22 +02:00
committed by GitHub
parent 8b92e85321
commit 33a1b8709d
+34 -2
View File
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useLayoutEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
@@ -43,11 +43,40 @@ export function VirtualCardGrid<T>({
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
const overscan = Math.max(2, Math.ceil(mainScrollViewportHeight / Math.max(1, rowHeightEst)));
// Vertical offset between the scroll element's top and this grid's top.
// react-virtual computes row positions relative to the scroll element, so
// without this margin a grid that sits below tall content (e.g. the
// "More by …" rail under a long tracklist) would have its first rows
// placed at negative positions and unmounted as out-of-viewport.
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
if (disableVirtualization) return;
const wrap = wrapRef.current;
const scrollEl = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (!wrap || !scrollEl) return;
const measure = () => {
const margin =
wrap.getBoundingClientRect().top
- scrollEl.getBoundingClientRect().top
+ scrollEl.scrollTop;
setScrollMargin(prev => (Math.abs(prev - margin) < 1 ? prev : margin));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(scrollEl);
// Anything above the grid resizing (tracklist growing, hero collapsing)
// shifts the grid down; observe the scroll content too so we re-measure.
const scrollContent = scrollEl.firstElementChild as Element | null;
if (scrollContent) ro.observe(scrollContent);
return () => ro.disconnect();
}, [disableVirtualization, layoutSignal, virtualRowCount]);
const virtualizer = useVirtualizer({
count: disableVirtualization ? 0 : virtualRowCount,
getScrollElement: () => document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID),
estimateSize: () => rowHeightEst,
overscan,
scrollMargin,
});
useRemeasureGridVirtualizer(virtualizer, {
@@ -93,6 +122,9 @@ export function VirtualCardGrid<T>({
{virtualizer.getVirtualItems().map(vRow => {
const start = vRow.index * cols;
const rowItems = items.slice(start, start + cols);
// `vRow.start` is measured from the scroll element's top; our wrapper
// already sits `scrollMargin` below that, so subtract to land back
// in wrapper-local coordinates.
return (
<div
key={vRow.key}
@@ -101,7 +133,7 @@ export function VirtualCardGrid<T>({
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
transform: `translateY(${vRow.start - scrollMargin}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gap: gridGap,