diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e67f5a..73c23186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,12 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa ## Fixed +### Artists — infinite scroll after first page + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#709](https://github.com/Psychotoxical/psysonic/pull/709)** + +* The **Artists** page only mounts the bottom **`IntersectionObserver`** sentinel after **`getArtists`** finishes, while **`useArtistsInfiniteScroll`** subscribed in an effect keyed only on **`loadMore`**. Unlike **Albums**, that callback does not depend on **`loading`**, so its identity did not change when the sentinel first appeared — the observer never attached and **`visibleCount`** never grew past the first batch. The hook now uses a **callback ref** (subscribe on mount, disconnect on unmount) and sets **`root`** to **`#app-main-scroll-viewport`** so intersection matches the real scroll container. + ### Playback — track no longer clipped at the end with gapless and crossfade off **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#708](https://github.com/Psychotoxical/psysonic/pull/708)** diff --git a/src/hooks/useArtistsInfiniteScroll.ts b/src/hooks/useArtistsInfiniteScroll.ts index 07340eba..eb9c472a 100644 --- a/src/hooks/useArtistsInfiniteScroll.ts +++ b/src/hooks/useArtistsInfiniteScroll.ts @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; interface UseArtistsInfiniteScrollArgs { pageSize: number; @@ -8,7 +9,8 @@ interface UseArtistsInfiniteScrollArgs { interface UseArtistsInfiniteScrollResult { visibleCount: number; loadingMore: boolean; - observerTarget: React.RefObject; + /** Callback ref — attaches IntersectionObserver when the sentinel mounts (fixes first paint behind `loading`). */ + observerTarget: React.RefCallback; loadMore: () => void; } @@ -25,7 +27,7 @@ interface UseArtistsInfiniteScrollResult { * The observer doesn't take a `hasMore` flag — the page only renders * the sentinel `
` while there is more data, * so the observer naturally disconnects when the last page is reached - * (the cleanup runs as the sentinel unmounts). + * (callback ref runs with `node === null` as the sentinel unmounts). */ export function useArtistsInfiniteScroll({ pageSize, @@ -33,7 +35,8 @@ export function useArtistsInfiniteScroll({ }: UseArtistsInfiniteScrollArgs): UseArtistsInfiniteScrollResult { const [visibleCount, setVisibleCount] = useState(pageSize); const [loadingMore, setLoadingMore] = useState(false); - const observerTarget = useRef(null); + const loadMoreRef = useRef<() => void>(() => {}); + const observerInst = useRef(null); const loadMore = useCallback(() => { if (loadingMore) return; @@ -42,20 +45,37 @@ export function useArtistsInfiniteScroll({ setTimeout(() => setLoadingMore(false), 100); }, [loadingMore, pageSize]); + loadMoreRef.current = loadMore; + useEffect(() => { setVisibleCount(pageSize); // resetDeps is intentionally spread into the dep array. // eslint-disable-next-line react-hooks/exhaustive-deps }, [pageSize, ...resetDeps]); - useEffect(() => { + const observerTarget = useCallback((node: HTMLDivElement | null) => { + observerInst.current?.disconnect(); + observerInst.current = null; + if (!node) return; + + const rootEl = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); const observer = new IntersectionObserver( - entries => { if (entries[0].isIntersecting) loadMore(); }, - { rootMargin: '200px' }, + entries => { + if (entries[0]?.isIntersecting) loadMoreRef.current(); + }, + { + root: rootEl instanceof HTMLElement ? rootEl : null, + rootMargin: '200px', + }, ); - if (observerTarget.current) observer.observe(observerTarget.current); - return () => observer.disconnect(); - }, [loadMore]); + observer.observe(node); + observerInst.current = observer; + }, []); + + useEffect(() => () => { + observerInst.current?.disconnect(); + observerInst.current = null; + }, []); return { visibleCount, loadingMore, observerTarget, loadMore }; }