feat(artists): preserve artist detail scroll position on back navigation (#2045)

* feat(artists): preserve artist detail scroll position on back navigation

* fix(artists): target OverlayScrollbars viewport child for scroll persistence

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

---------

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
This commit is contained in:
Sai Asish Y
2026-06-19 21:55:41 -07:00
committed by GitHub
parent dbe46e03a4
commit 36624350f6
2 changed files with 65 additions and 0 deletions
@@ -0,0 +1,62 @@
import { RefObject, useEffect, useLayoutEffect } from 'react';
import { useLocation, useNavigationType } from 'react-router';
import { useScrollStore } from '/@/renderer/store/scroll.store';
interface UseNativeScrollPersistProps {
enabled: boolean;
scrollRef: RefObject<HTMLDivElement | null>;
}
// OverlayScrollbars initializes on the NativeScrollArea container and moves the
// content into a viewport child element; that child is what actually scrolls,
// so scrollTop must be read from and written to it rather than the container
// the ref points at.
const getScrollNode = (scrollRef: RefObject<HTMLDivElement | null>): HTMLElement | null => {
const node = scrollRef.current?.children[0];
return node instanceof HTMLElement ? node : null;
};
// Persists vertical scroll offset for a NativeScrollArea, keyed by react-router
// location.key. Restores the saved offset only on POP navigation; PUSH/REPLACE
// continue to start at the top.
export const useNativeScrollPersist = ({ enabled, scrollRef }: UseNativeScrollPersistProps) => {
const location = useLocation();
const navigationType = useNavigationType();
const setOffset = useScrollStore((s) => s.setOffset);
const getOffset = useScrollStore((s) => s.getOffset);
useLayoutEffect(() => {
const saved = getOffset(location.key);
if (!enabled || navigationType !== 'POP' || typeof saved !== 'number') {
return;
}
const applyOffset = () => {
const node = getScrollNode(scrollRef);
if (node) {
node.scrollTop = saved;
}
};
applyOffset();
const raf = requestAnimationFrame(applyOffset);
return () => cancelAnimationFrame(raf);
}, [enabled, getOffset, location.key, navigationType, scrollRef]);
useEffect(() => {
const node = getScrollNode(scrollRef);
if (!enabled || !node) {
return;
}
const handleScroll = () => {
setOffset(location.key, node.scrollTop);
};
node.addEventListener('scroll', handleScroll, { passive: true });
return () => {
node.removeEventListener('scroll', handleScroll);
};
}, [enabled, location.key, scrollRef, setOffset]);
};
@@ -4,6 +4,7 @@ import { useParams } from 'react-router';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
import { useNativeScrollPersist } from '/@/renderer/components/native-scroll-area/use-native-scroll-persist';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
@@ -28,6 +29,8 @@ const AlbumArtistDetailRouteContent = () => {
const serverId = useCurrentServerId();
const { artistBackground, artistBackgroundBlur } = useArtistBackground();
useNativeScrollPersist({ enabled: true, scrollRef: scrollAreaRef });
const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string;
artistId?: string;