From 33a1b8709d5f076bf9f19f132f133ef38296bf98 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 17 May 2026 23:26:22 +0200 Subject: [PATCH] fix(grid): apply scrollMargin so VirtualCardGrid renders below tall content (#764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/VirtualCardGrid.tsx | 36 ++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/VirtualCardGrid.tsx b/src/components/VirtualCardGrid.tsx index 8b6ad7d2..e4231f76 100644 --- a/src/components/VirtualCardGrid.tsx +++ b/src/components/VirtualCardGrid.tsx @@ -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({ 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({ {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 (
({ 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,