From 54d5f75879276a84b10512303e928932a3b37b41 Mon Sep 17 00:00:00 2001 From: Boris Rorsvort Date: Mon, 12 Jan 2026 14:33:18 +0100 Subject: [PATCH 1/5] feat(ui): Add inifinite scrool to album list - #132 Signed-off-by: Boris Rorsvort --- ui/src/App.jsx | 2 + ui/src/actions/index.js | 1 + ui/src/actions/infiniteScroll.js | 6 + ui/src/album/AlbumList.jsx | 25 +- ui/src/common/InfinitePagination.jsx | 5 + ui/src/common/InfiniteScrollWrapper.jsx | 72 ++++++ ui/src/common/index.js | 3 + ui/src/common/useInfiniteScroll.jsx | 316 +++++++++++++++++++++++ ui/src/i18n/en.json | 1 + ui/src/personal/InfiniteScrollToggle.jsx | 34 +++ ui/src/personal/Personal.jsx | 2 + ui/src/reducers/index.js | 1 + ui/src/reducers/infiniteScrollReducer.js | 18 ++ ui/src/store/createAdminStore.js | 2 + 14 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 ui/src/actions/infiniteScroll.js create mode 100644 ui/src/common/InfinitePagination.jsx create mode 100644 ui/src/common/InfiniteScrollWrapper.jsx create mode 100644 ui/src/common/useInfiniteScroll.jsx create mode 100644 ui/src/personal/InfiniteScrollToggle.jsx create mode 100644 ui/src/reducers/infiniteScrollReducer.js diff --git a/ui/src/App.jsx b/ui/src/App.jsx index dc4fe9b53..2fdaa47a7 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -30,6 +30,7 @@ import { activityReducer, settingsReducer, replayGainReducer, + infiniteScrollReducer, downloadMenuDialogReducer, shareDialogReducer, } from './reducers' @@ -71,6 +72,7 @@ const adminStore = createAdminStore({ activity: activityReducer, settings: settingsReducer, replayGain: replayGainReducer, + infiniteScroll: infiniteScrollReducer, }, }) diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index 9f35f86a9..1fcd70317 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -6,3 +6,4 @@ export * from './dialogs' export * from './replayGain' export * from './serverEvents' export * from './settings' +export * from './infiniteScroll' diff --git a/ui/src/actions/infiniteScroll.js b/ui/src/actions/infiniteScroll.js new file mode 100644 index 000000000..8bb8d2f61 --- /dev/null +++ b/ui/src/actions/infiniteScroll.js @@ -0,0 +1,6 @@ +export const SET_INFINITE_SCROLL = 'SET_INFINITE_SCROLL' + +export const setInfiniteScroll = (enabled) => ({ + type: SET_INFINITE_SCROLL, + data: enabled, +}) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index f10f8dbd3..011026ab3 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -18,6 +18,8 @@ import { import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { + InfinitePagination, + InfiniteScrollWrapper, List, QuickFilter, Title, @@ -179,6 +181,9 @@ const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { const { width } = props const albumView = useSelector((state) => state.albumView) + const infiniteScrollEnabled = useSelector( + (state) => state.infiniteScroll?.enabled ?? false, + ) const [perPage, perPageOptions] = useAlbumsPerPage(width) const location = useLocation() const version = useVersion() @@ -234,14 +239,22 @@ const AlbumList = (props) => { actions={} filters={} perPage={perPage} - pagination={} + pagination={ + infiniteScrollEnabled ? ( + + ) : ( + + ) + } title={} > - {albumView.grid ? ( - - ) : ( - - )} + + {albumView.grid ? ( + + ) : ( + + )} + } /> diff --git a/ui/src/common/InfinitePagination.jsx b/ui/src/common/InfinitePagination.jsx new file mode 100644 index 000000000..35e1f0bda --- /dev/null +++ b/ui/src/common/InfinitePagination.jsx @@ -0,0 +1,5 @@ +import React from 'react' + +export const InfinitePagination = () => { + return null +} diff --git a/ui/src/common/InfiniteScrollWrapper.jsx b/ui/src/common/InfiniteScrollWrapper.jsx new file mode 100644 index 000000000..4ccdaaa7a --- /dev/null +++ b/ui/src/common/InfiniteScrollWrapper.jsx @@ -0,0 +1,72 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { CircularProgress, Box } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { InfiniteScrollProvider, useInfiniteScroll } from './useInfiniteScroll' + +const useStyles = makeStyles((theme) => ({ + sentinel: { + width: '100%', + height: '1px', + marginTop: theme.spacing(2), + }, + loadingContainer: { + display: 'flex', + justifyContent: 'center', + padding: theme.spacing(2), + width: '100%', + }, +})) + +const InfiniteScrollContent = ({ children, ...props }) => { + const classes = useStyles() + const { + accumulatedData, + accumulatedIds, + loading, + loaded, + hasNextPage, + isFetchingMore, + sentinelRef, + } = useInfiniteScroll() + + const infiniteProps = { + ...props, + data: accumulatedIds.length > 0 ? accumulatedData : props.data, + ids: accumulatedIds.length > 0 ? accumulatedIds : props.ids, + loading, + loaded, + } + + return ( + <> + {React.Children.map(children, (child) => + React.cloneElement(child, infiniteProps), + )} + {isFetchingMore && ( + + + + )} + {hasNextPage &&
} + + ) +} + +export const InfiniteScrollWrapper = ({ children, ...props }) => { + const infiniteScrollEnabled = useSelector( + (state) => state.infiniteScroll?.enabled ?? false, + ) + + if (!infiniteScrollEnabled) { + return React.Children.map(children, (child) => + React.cloneElement(child, props), + ) + } + + return ( + + {children} + + ) +} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index f64d4fe0c..b62aea2b5 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -41,3 +41,6 @@ export * from './formatRange.js' export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' +export * from './useInfiniteScroll.jsx' +export * from './InfinitePagination' +export * from './InfiniteScrollWrapper' diff --git a/ui/src/common/useInfiniteScroll.jsx b/ui/src/common/useInfiniteScroll.jsx new file mode 100644 index 000000000..a27aa57af --- /dev/null +++ b/ui/src/common/useInfiniteScroll.jsx @@ -0,0 +1,316 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useCallback, + useState, + useMemo, +} from 'react' +import { useListContext, useGetList } from 'react-admin' +import { useHistory, useLocation } from 'react-router-dom' + +const InfiniteScrollContext = createContext(null) + +const APP_BAR_HEIGHT = 64 + +const getStorageKey = (pathname, filterValues) => { + const filterKey = JSON.stringify(filterValues || {}) + return `infiniteScroll:${pathname}:${filterKey}` +} + +const saveScrollState = (pathname, filterValues, pagesLoaded, scrollY) => { + const key = getStorageKey(pathname, filterValues) + sessionStorage.setItem(key, JSON.stringify({ pagesLoaded, scrollY })) +} + +const getScrollState = (pathname, filterValues) => { + const key = getStorageKey(pathname, filterValues) + const saved = sessionStorage.getItem(key) + if (saved) { + try { + return JSON.parse(saved) + } catch { + return null + } + } + return null +} + +const clearScrollState = (pathname, filterValues) => { + const key = getStorageKey(pathname, filterValues) + sessionStorage.removeItem(key) +} + +export const InfiniteScrollProvider = ({ children }) => { + const history = useHistory() + const location = useLocation() + + const { + data: page1Data, + ids: page1Ids, + total, + perPage, + loading: page1Loading, + loaded: page1Loaded, + resource, + filterValues, + sort, + filter, + } = useListContext() + + const [nextPageToFetch, setNextPageToFetch] = useState(null) + const [additionalPagesData, setAdditionalPagesData] = useState({}) + const [isFetchingMore, setIsFetchingMore] = useState(false) + const [isRestoring, setIsRestoring] = useState(false) + const [targetPage, setTargetPage] = useState(null) + const [pendingScrollY, setPendingScrollY] = useState(null) + + const initializedRef = useRef(false) + const navigationTypeRef = useRef(null) + const sentinelRef = useRef(null) + + const combinedFilter = useMemo( + () => ({ + ...filter, + ...filterValues, + }), + [filter, filterValues], + ) + + useEffect(() => { + if (typeof window !== 'undefined' && window.performance) { + const navEntries = window.performance.getEntriesByType('navigation') + if (navEntries.length > 0) { + navigationTypeRef.current = navEntries[0].type + } + } + }, []) + + const { data: additionalData, ids: additionalIds, loaded: additionalLoaded } = + useGetList( + resource, + { page: nextPageToFetch || 1, perPage }, + sort, + combinedFilter, + { enabled: nextPageToFetch !== null && nextPageToFetch > 1 }, + ) + + const maxLoadedPage = useMemo(() => { + const additionalPages = Object.keys(additionalPagesData).map(Number) + if (additionalPages.length > 0) { + return Math.max(...additionalPages) + } + return page1Loaded ? 1 : 0 + }, [additionalPagesData, page1Loaded]) + + const hasNextPage = useMemo(() => { + if (!total || !perPage) return false + const totalPages = Math.ceil(total / perPage) + return maxLoadedPage < totalPages + }, [total, perPage, maxLoadedPage]) + + const { accumulatedData, accumulatedIds } = useMemo(() => { + const accData = { ...page1Data } + const accIds = [...(page1Ids || [])] + const seenIds = new Set(accIds) + + const sortedPages = Object.keys(additionalPagesData) + .map(Number) + .sort((a, b) => a - b) + + for (const pageNum of sortedPages) { + const pageInfo = additionalPagesData[pageNum] + if (pageInfo) { + Object.assign(accData, pageInfo.data) + for (const id of pageInfo.ids) { + if (!seenIds.has(id)) { + seenIds.add(id) + accIds.push(id) + } + } + } + } + + return { accumulatedData: accData, accumulatedIds: accIds } + }, [page1Data, page1Ids, additionalPagesData]) + + useEffect(() => { + if (initializedRef.current || !page1Loaded) return + initializedRef.current = true + + const isBackNavigation = + history.action === 'POP' || navigationTypeRef.current === 'back_forward' + + if (isBackNavigation) { + const saved = getScrollState(location.pathname, filterValues) + if (saved && saved.pagesLoaded > 1) { + setIsRestoring(true) + setTargetPage(saved.pagesLoaded) + if (saved.scrollY > 0) { + setPendingScrollY(saved.scrollY) + } + setNextPageToFetch(2) + return + } + } + + clearScrollState(location.pathname, filterValues) + window.scrollTo(0, 0) + }, [page1Loaded, history.action, location.pathname, filterValues]) + + useEffect(() => { + if ( + nextPageToFetch && + nextPageToFetch > 1 && + additionalLoaded && + additionalData && + additionalIds + ) { + setAdditionalPagesData((prev) => { + if (!prev[nextPageToFetch]) { + return { + ...prev, + [nextPageToFetch]: { + data: { ...additionalData }, + ids: [...additionalIds], + }, + } + } + return prev + }) + + setIsFetchingMore(false) + + if (isRestoring && targetPage && nextPageToFetch < targetPage) { + setNextPageToFetch(nextPageToFetch + 1) + } else if (isRestoring && targetPage && nextPageToFetch >= targetPage) { + if (pendingScrollY) { + setTimeout(() => { + window.scrollTo(0, pendingScrollY) + setPendingScrollY(null) + setIsRestoring(false) + setTargetPage(null) + }, 100) + } else { + setIsRestoring(false) + setTargetPage(null) + } + } + } + }, [ + nextPageToFetch, + additionalLoaded, + additionalData, + additionalIds, + isRestoring, + targetPage, + pendingScrollY, + ]) + + useEffect(() => { + if (!isRestoring && maxLoadedPage > 0 && page1Loaded) { + const handleScroll = () => { + saveScrollState( + location.pathname, + filterValues, + maxLoadedPage, + window.scrollY, + ) + } + + handleScroll() + + let scrollTimeout + const throttledScroll = () => { + clearTimeout(scrollTimeout) + scrollTimeout = setTimeout(handleScroll, 150) + } + + window.addEventListener('scroll', throttledScroll, { passive: true }) + return () => { + window.removeEventListener('scroll', throttledScroll) + clearTimeout(scrollTimeout) + } + } + }, [maxLoadedPage, isRestoring, location.pathname, filterValues, page1Loaded]) + + const loadMore = useCallback(() => { + if (!page1Loading && !isFetchingMore && hasNextPage && !isRestoring) { + setIsFetchingMore(true) + setNextPageToFetch(maxLoadedPage + 1) + } + }, [page1Loading, isFetchingMore, hasNextPage, maxLoadedPage, isRestoring]) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if ( + entry.isIntersecting && + hasNextPage && + !page1Loading && + !isFetchingMore && + !isRestoring + ) { + loadMore() + } + }, + { + root: null, + rootMargin: '200px', + threshold: 0, + }, + ) + + observer.observe(sentinel) + + return () => { + observer.disconnect() + } + }, [hasNextPage, page1Loading, isFetchingMore, loadMore, isRestoring]) + + const value = useMemo( + () => ({ + accumulatedData, + accumulatedIds, + hasNextPage, + isFetchingMore, + loading: page1Loading || isFetchingMore || isRestoring, + sentinelRef, + total, + loaded: page1Loaded && !isRestoring, + loadMore, + }), + [ + accumulatedData, + accumulatedIds, + hasNextPage, + isFetchingMore, + page1Loading, + isRestoring, + total, + page1Loaded, + loadMore, + ], + ) + + return ( + + {children} + + ) +} + +export const useInfiniteScroll = () => { + const context = useContext(InfiniteScrollContext) + if (!context) { + throw new Error( + 'useInfiniteScroll must be used within an InfiniteScrollProvider', + ) + } + return context +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 9ef65d668..091aff107 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -533,6 +533,7 @@ "language": "Language", "defaultView": "Default View", "desktop_notifications": "Desktop Notifications", + "infinite_scroll": "Infinite scroll on album list", "lastfmNotConfigured": "Last.fm API-Key is not configured", "lastfmScrobbling": "Scrobble to Last.fm", "listenBrainzScrobbling": "Scrobble to ListenBrainz", diff --git a/ui/src/personal/InfiniteScrollToggle.jsx b/ui/src/personal/InfiniteScrollToggle.jsx new file mode 100644 index 000000000..9694b3981 --- /dev/null +++ b/ui/src/personal/InfiniteScrollToggle.jsx @@ -0,0 +1,34 @@ +import { useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { setInfiniteScroll } from '../actions' +import { FormControl, FormControlLabel, Switch } from '@material-ui/core' + +export const InfiniteScrollToggle = () => { + const translate = useTranslate() + const dispatch = useDispatch() + const currentSetting = useSelector( + (state) => state.infiniteScroll?.enabled ?? false, + ) + + const toggleInfiniteScroll = (event) => { + dispatch(setInfiniteScroll(event.target.checked)) + } + + return ( + + + } + label={ + {translate('menu.personal.options.infinite_scroll')} + } + /> + + ) +} diff --git a/ui/src/personal/Personal.jsx b/ui/src/personal/Personal.jsx index 84f9b63e6..0f01ff62b 100644 --- a/ui/src/personal/Personal.jsx +++ b/ui/src/personal/Personal.jsx @@ -9,6 +9,7 @@ import { LastfmScrobbleToggle } from './LastfmScrobbleToggle' import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle' import config from '../config' import { ReplayGainToggle } from './ReplayGainToggle' +import { InfiniteScrollToggle } from './InfiniteScrollToggle' const useStyles = makeStyles({ root: { marginTop: '1em' }, @@ -26,6 +27,7 @@ const Personal = () => { {config.enableReplayGain && } + {config.lastFMEnabled && } {config.listenBrainzEnabled && } diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index 3db0b1dff..a904a3f6e 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -6,3 +6,4 @@ export * from './albumView' export * from './activityReducer' export * from './settingsReducer' export * from './replayGainReducer' +export * from './infiniteScrollReducer' diff --git a/ui/src/reducers/infiniteScrollReducer.js b/ui/src/reducers/infiniteScrollReducer.js new file mode 100644 index 000000000..7244bc5ef --- /dev/null +++ b/ui/src/reducers/infiniteScrollReducer.js @@ -0,0 +1,18 @@ +import { SET_INFINITE_SCROLL } from '../actions' + +const initialState = { + enabled: false, +} + +export const infiniteScrollReducer = (previousState = initialState, payload) => { + const { type, data } = payload + switch (type) { + case SET_INFINITE_SCROLL: + return { + ...previousState, + enabled: data, + } + default: + return previousState + } +} diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js index 4888e49e4..9ea168d19 100644 --- a/ui/src/store/createAdminStore.js +++ b/ui/src/store/createAdminStore.js @@ -65,6 +65,8 @@ const createAdminStore = ({ }))(state.player), albumView: state.albumView, settings: state.settings, + // Only persist the 'enabled' setting, not transient page/scroll state + infiniteScroll: { enabled: state.infiniteScroll?.enabled ?? false }, }) }), 1000, From 1f3567ffdc56be3fa63bf222548f4ee81e26610c Mon Sep 17 00:00:00 2001 From: Boris Rorsvort Date: Mon, 12 Jan 2026 14:35:20 +0100 Subject: [PATCH 2/5] fix: linting --- ui/src/common/useInfiniteScroll.jsx | 19 +++++++++++-------- ui/src/reducers/infiniteScrollReducer.js | 5 ++++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/ui/src/common/useInfiniteScroll.jsx b/ui/src/common/useInfiniteScroll.jsx index a27aa57af..9092581e1 100644 --- a/ui/src/common/useInfiniteScroll.jsx +++ b/ui/src/common/useInfiniteScroll.jsx @@ -87,14 +87,17 @@ export const InfiniteScrollProvider = ({ children }) => { } }, []) - const { data: additionalData, ids: additionalIds, loaded: additionalLoaded } = - useGetList( - resource, - { page: nextPageToFetch || 1, perPage }, - sort, - combinedFilter, - { enabled: nextPageToFetch !== null && nextPageToFetch > 1 }, - ) + const { + data: additionalData, + ids: additionalIds, + loaded: additionalLoaded, + } = useGetList( + resource, + { page: nextPageToFetch || 1, perPage }, + sort, + combinedFilter, + { enabled: nextPageToFetch !== null && nextPageToFetch > 1 }, + ) const maxLoadedPage = useMemo(() => { const additionalPages = Object.keys(additionalPagesData).map(Number) diff --git a/ui/src/reducers/infiniteScrollReducer.js b/ui/src/reducers/infiniteScrollReducer.js index 7244bc5ef..49b3db5f2 100644 --- a/ui/src/reducers/infiniteScrollReducer.js +++ b/ui/src/reducers/infiniteScrollReducer.js @@ -4,7 +4,10 @@ const initialState = { enabled: false, } -export const infiniteScrollReducer = (previousState = initialState, payload) => { +export const infiniteScrollReducer = ( + previousState = initialState, + payload, +) => { const { type, data } = payload switch (type) { case SET_INFINITE_SCROLL: From 45b651e9942e68b02b13cf5fc3344515bca7ba95 Mon Sep 17 00:00:00 2001 From: Boris Rorsvort Date: Mon, 12 Jan 2026 14:38:42 +0100 Subject: [PATCH 3/5] cleanup --- ui/src/common/InfinitePagination.jsx | 6 +----- ui/src/common/useInfiniteScroll.jsx | 3 +-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ui/src/common/InfinitePagination.jsx b/ui/src/common/InfinitePagination.jsx index 35e1f0bda..342d8148d 100644 --- a/ui/src/common/InfinitePagination.jsx +++ b/ui/src/common/InfinitePagination.jsx @@ -1,5 +1 @@ -import React from 'react' - -export const InfinitePagination = () => { - return null -} +export const InfinitePagination = () => null diff --git a/ui/src/common/useInfiniteScroll.jsx b/ui/src/common/useInfiniteScroll.jsx index 9092581e1..a9ecf4f41 100644 --- a/ui/src/common/useInfiniteScroll.jsx +++ b/ui/src/common/useInfiniteScroll.jsx @@ -12,8 +12,6 @@ import { useHistory, useLocation } from 'react-router-dom' const InfiniteScrollContext = createContext(null) -const APP_BAR_HEIGHT = 64 - const getStorageKey = (pathname, filterValues) => { const filterKey = JSON.stringify(filterValues || {}) return `infiniteScroll:${pathname}:${filterKey}` @@ -308,6 +306,7 @@ export const InfiniteScrollProvider = ({ children }) => { ) } +// eslint-disable-next-line react-refresh/only-export-components export const useInfiniteScroll = () => { const context = useContext(InfiniteScrollContext) if (!context) { From ff26593b17bd95abf0d489edfd2964c398f39566 Mon Sep 17 00:00:00 2001 From: Boris Rorsvort Date: Mon, 12 Jan 2026 14:49:33 +0100 Subject: [PATCH 4/5] simplify persistence of page and scroll --- ui/src/album/AlbumList.jsx | 3 +-- ui/src/common/InfinitePagination.jsx | 1 - ui/src/common/index.js | 1 - ui/src/common/useInfiniteScroll.jsx | 18 ++++++------------ 4 files changed, 7 insertions(+), 16 deletions(-) delete mode 100644 ui/src/common/InfinitePagination.jsx diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 011026ab3..d9c7d1066 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -18,7 +18,6 @@ import { import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { - InfinitePagination, InfiniteScrollWrapper, List, QuickFilter, @@ -241,7 +240,7 @@ const AlbumList = (props) => { perPage={perPage} pagination={ infiniteScrollEnabled ? ( - + false ) : ( ) diff --git a/ui/src/common/InfinitePagination.jsx b/ui/src/common/InfinitePagination.jsx deleted file mode 100644 index 342d8148d..000000000 --- a/ui/src/common/InfinitePagination.jsx +++ /dev/null @@ -1 +0,0 @@ -export const InfinitePagination = () => null diff --git a/ui/src/common/index.js b/ui/src/common/index.js index b62aea2b5..690fb76b9 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -42,5 +42,4 @@ export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' export * from './useInfiniteScroll.jsx' -export * from './InfinitePagination' export * from './InfiniteScrollWrapper' diff --git a/ui/src/common/useInfiniteScroll.jsx b/ui/src/common/useInfiniteScroll.jsx index a9ecf4f41..081514da2 100644 --- a/ui/src/common/useInfiniteScroll.jsx +++ b/ui/src/common/useInfiniteScroll.jsx @@ -12,32 +12,26 @@ import { useHistory, useLocation } from 'react-router-dom' const InfiniteScrollContext = createContext(null) +const scrollStateCache = new Map() + const getStorageKey = (pathname, filterValues) => { const filterKey = JSON.stringify(filterValues || {}) - return `infiniteScroll:${pathname}:${filterKey}` + return `${pathname}:${filterKey}` } const saveScrollState = (pathname, filterValues, pagesLoaded, scrollY) => { const key = getStorageKey(pathname, filterValues) - sessionStorage.setItem(key, JSON.stringify({ pagesLoaded, scrollY })) + scrollStateCache.set(key, { pagesLoaded, scrollY }) } const getScrollState = (pathname, filterValues) => { const key = getStorageKey(pathname, filterValues) - const saved = sessionStorage.getItem(key) - if (saved) { - try { - return JSON.parse(saved) - } catch { - return null - } - } - return null + return scrollStateCache.get(key) || null } const clearScrollState = (pathname, filterValues) => { const key = getStorageKey(pathname, filterValues) - sessionStorage.removeItem(key) + scrollStateCache.delete(key) } export const InfiniteScrollProvider = ({ children }) => { From b77ea704d5136adef9ce2163033ef841064637ca Mon Sep 17 00:00:00 2001 From: Boris Rorsvort Date: Mon, 12 Jan 2026 14:56:49 +0100 Subject: [PATCH 5/5] fix pr comment --- ui/src/common/InfiniteScrollWrapper.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/common/InfiniteScrollWrapper.jsx b/ui/src/common/InfiniteScrollWrapper.jsx index 4ccdaaa7a..fa0328a05 100644 --- a/ui/src/common/InfiniteScrollWrapper.jsx +++ b/ui/src/common/InfiniteScrollWrapper.jsx @@ -32,8 +32,8 @@ const InfiniteScrollContent = ({ children, ...props }) => { const infiniteProps = { ...props, - data: accumulatedIds.length > 0 ? accumulatedData : props.data, - ids: accumulatedIds.length > 0 ? accumulatedIds : props.ids, + data: accumulatedData, + ids: accumulatedIds, loading, loaded, }