From 36624350f607e97ad48def2095a277598ad63ec8 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Fri, 19 Jun 2026 21:55:41 -0700 Subject: [PATCH] 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 --------- Signed-off-by: Sai Asish Y --- .../use-native-scroll-persist.ts | 62 +++++++++++++++++++ .../routes/album-artist-detail-route.tsx | 3 + 2 files changed, 65 insertions(+) create mode 100644 src/renderer/components/native-scroll-area/use-native-scroll-persist.ts diff --git a/src/renderer/components/native-scroll-area/use-native-scroll-persist.ts b/src/renderer/components/native-scroll-area/use-native-scroll-persist.ts new file mode 100644 index 000000000..55ba6fb15 --- /dev/null +++ b/src/renderer/components/native-scroll-area/use-native-scroll-persist.ts @@ -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; +} + +// 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): 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]); +}; diff --git a/src/renderer/features/artists/routes/album-artist-detail-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-route.tsx index 874f4de6f..36d53bfc5 100644 --- a/src/renderer/features/artists/routes/album-artist-detail-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-detail-route.tsx @@ -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;