Files
psysonic/src/hooks/useVirtualizerScrollMargin.ts
T
Frank Stellmacher 48a69754d6 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
2026-05-18 01:27:38 +02:00

48 lines
1.8 KiB
TypeScript

import { useLayoutEffect, useRef, useState } from 'react';
import type React from 'react';
/**
* Distance between a virtualizer's wrapper element and its scroll element.
* Pass as `scrollMargin` to `useVirtualizer`, and subtract from `vItem.start`
* when positioning rendered rows. Without it, `@tanstack/react-virtual`
* places rows relative to the scroll-element top — so a wrapper that sits
* below other content (sticky header, tracklist, hero) ends up with rows at
* the wrong Y, and rows still in the viewport may unmount.
*
* Re-measures via `ResizeObserver` on the scroll element and its first child
* so content growing above the wrapper updates the offset.
*/
export function useVirtualizerScrollMargin(
wrapRef: React.RefObject<HTMLElement | null>,
getScrollElement: () => HTMLElement | null,
options: {
active: boolean;
/** Caller-supplied dependencies that affect the wrapper's position. */
deps: ReadonlyArray<unknown>;
},
): number {
const [scrollMargin, setScrollMargin] = useState(0);
const getterRef = useRef(getScrollElement);
getterRef.current = getScrollElement;
useLayoutEffect(() => {
if (!options.active) return;
const wrap = wrapRef.current;
const scrollEl = getterRef.current();
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);
const scrollContent = scrollEl.firstElementChild as Element | null;
if (scrollContent) ro.observe(scrollContent);
return () => ro.disconnect();
}, [options.active, wrapRef, ...options.deps]);
return scrollMargin;
}