Restore virtuoso states on browser back navigation

- manga chapter list
- library duplicates
- updates
- history
- download queue
This commit is contained in:
schroda
2025-05-25 15:36:25 +02:00
parent 5f982e07e0
commit c3b22f92a9
13 changed files with 209 additions and 82 deletions

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { ComponentProps, useRef } from 'react';
import { GroupedVirtuoso, GroupedVirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { useMergedRef } from '@mantine/hooks';
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
export const GroupedVirtuosoPersisted = ({
ref: passedRef,
persistKey,
...props
}: ComponentProps<typeof GroupedVirtuoso> & { persistKey: string }) => {
const { state, persistState } = VirtuosoUtil.usePersistState<StateSnapshot>(persistKey);
const localRef = useRef<GroupedVirtuosoHandle>(undefined);
const ref = useMergedRef(localRef, passedRef);
return (
<GroupedVirtuoso
{...props}
ref={ref}
isScrolling={(isScrolling) => {
if (!isScrolling) {
localRef.current?.getState(persistState);
}
props.isScrolling?.(isScrolling);
}}
restoreStateFrom={state}
/>
);
};

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { ComponentProps, useRef } from 'react';
import { GridStateSnapshot, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso';
import { useMergedRef } from '@mantine/hooks';
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
export const VirtuosoGridPersisted = ({
ref: passedRef,
persistKey,
...props
}: ComponentProps<typeof VirtuosoGrid> & { persistKey: string }) => {
const { state, persistState } = VirtuosoUtil.usePersistState<GridStateSnapshot>(persistKey);
const localRef = useRef<VirtuosoGridHandle>(undefined);
const ref = useMergedRef(localRef, passedRef);
return <VirtuosoGrid {...props} ref={ref} stateChanged={persistState} restoreStateFrom={state} />;
};

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { ComponentProps, useRef } from 'react';
import { StateSnapshot, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { useMergedRef } from '@mantine/hooks';
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
export const VirtuosoPersisted = ({
ref: passedRef,
persistKey,
...props
}: ComponentProps<typeof Virtuoso> & { persistKey: string }) => {
const { state, persistState } = VirtuosoUtil.usePersistState<StateSnapshot>(persistKey);
const localRef = useRef<VirtuosoHandle>(undefined);
const ref = useMergedRef(localRef, passedRef);
return (
<Virtuoso
{...props}
ref={ref}
isScrolling={(isScrolling) => {
if (!isScrolling) {
localRef.current?.getState(persistState);
}
props.isScrolling?.(isScrolling);
}}
restoreStateFrom={state}
/>
);
};

View File

@@ -6,7 +6,10 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useSessionStorage } from '@/modules/core/hooks/useStorage.tsx';
import { AppStorage } from '@/lib/storage/AppStorage.ts';
export class VirtuosoUtil { export class VirtuosoUtil {
static readonly GROUP = 0; static readonly GROUP = 0;
@@ -112,4 +115,46 @@ export class VirtuosoUtil {
[convertIndex, getGroupKey, getNormalKey], [convertIndex, getGroupKey, getNormalKey],
); );
} }
static usePersistState<Snapshot>(key: string): {
key: string;
state: Snapshot | undefined;
persistState: (state: Snapshot) => void;
deleteState: () => void;
} {
const location = useLocation<{ snapshot?: Snapshot }>();
const snapshotSessionKey = `virtuoso-snapshot-${key}-${location.key}`;
const [snapshot] = useSessionStorage<Snapshot | undefined>(snapshotSessionKey, undefined);
const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const persistState = useCallback(
(state: Snapshot) => {
const currentUrl = window.location.href;
clearTimeout(persistGridStateTimeout.current);
persistGridStateTimeout.current = setTimeout(() => {
const didLocationChange = currentUrl !== window.location.href;
if (didLocationChange) {
return;
}
AppStorage.session.setItem(snapshotSessionKey, state, false);
}, 250);
},
[snapshotSessionKey],
);
const deleteState = useCallback(() => {
AppStorage.session.setItem(snapshotSessionKey, undefined, false);
}, [snapshotSessionKey]);
useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]);
return {
key: snapshotSessionKey,
state: snapshot,
persistState,
deleteState,
};
}
} }

View File

@@ -11,7 +11,6 @@ import Stack from '@mui/material/Stack';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { ComponentProps, useCallback, useMemo, useState } from 'react'; import { ComponentProps, useCallback, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ResumeFab } from '@/modules/manga/components/ResumeFAB.tsx'; import { ResumeFab } from '@/modules/manga/components/ResumeFAB.tsx';
@@ -45,6 +44,7 @@ import { shouldForwardProp } from '@/modules/core/utils/ShouldForwardProp.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { makeToast } from '@/modules/core/utils/Toast.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts';
import { ChapterListCard } from '@/modules/chapter/components/cards/ChapterListCard.tsx'; import { ChapterListCard } from '@/modules/chapter/components/cards/ChapterListCard.tsx';
import { VirtuosoPersisted } from '@/lib/virtuoso/Component/VirtuosoPersisted.tsx';
type ChapterListHeaderProps = { type ChapterListHeaderProps = {
scrollbarWidth: number; scrollbarWidth: number;
@@ -61,7 +61,7 @@ const ChapterListHeader = styled(Stack, {
})); }));
type StyledVirtuosoProps = { topOffset: number }; type StyledVirtuosoProps = { topOffset: number };
const StyledVirtuoso = styled(Virtuoso, { const StyledVirtuoso = styled(VirtuosoPersisted, {
shouldForwardProp: shouldForwardProp<StyledVirtuosoProps>(['topOffset']), shouldForwardProp: shouldForwardProp<StyledVirtuosoProps>(['topOffset']),
})<StyledVirtuosoProps>(({ theme, topOffset }) => ({ })<StyledVirtuosoProps>(({ theme, topOffset }) => ({
listStyle: 'none', listStyle: 'none',
@@ -234,6 +234,7 @@ export const ChapterList = ({
)} )}
<StyledVirtuoso <StyledVirtuoso
persistKey={`manga-${manga.id}-chapter-list`}
topOffset={appBarHeight + chapterListHeaderHeight} topOffset={appBarHeight + chapterListHeaderHeight}
style={{ style={{
// override Virtuoso default values and set them with class // override Virtuoso default values and set them with class

View File

@@ -6,10 +6,11 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { ContextProp, GroupedVirtuoso, TopItemListProps } from 'react-virtuoso'; import { ContextProp, TopItemListProps } from 'react-virtuoso';
import { ComponentProps, useMemo } from 'react'; import { ComponentProps, useMemo } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { GroupedVirtuosoPersisted } from '@/lib/virtuoso/Component/GroupedVirtuosoPersisted.tsx';
const StickyVirtuosoHeaderWithOffset = const StickyVirtuosoHeaderWithOffset =
(topOffset: number) => (topOffset: number) =>
@@ -23,7 +24,7 @@ export const StyledGroupedVirtuoso = ({
heightToSubtract = 0, heightToSubtract = 0,
style, style,
...props ...props
}: ComponentProps<typeof GroupedVirtuoso> & { heightToSubtract?: number }) => { }: ComponentProps<typeof GroupedVirtuosoPersisted> & { heightToSubtract?: number }) => {
const { appBarHeight, bottomBarHeight } = useNavBarContext(); const { appBarHeight, bottomBarHeight } = useNavBarContext();
const TopItemList = useMemo( const TopItemList = useMemo(
@@ -32,7 +33,7 @@ export const StyledGroupedVirtuoso = ({
); );
return ( return (
<GroupedVirtuoso <GroupedVirtuosoPersisted
useWindowScroll useWindowScroll
{...props} {...props}
components={{ components={{

View File

@@ -13,7 +13,6 @@ import IconButton from '@mui/material/IconButton';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import { Virtuoso } from 'react-virtuoso';
import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core'; import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useWindowEvent } from '@mantine/hooks'; import { useWindowEvent } from '@mantine/hooks';
@@ -32,6 +31,7 @@ import { DownloadQueueChapterCard } from '@/modules/downloads/components/Downloa
import { useAppTitle } from '@/modules/navigation-bar/hooks/useAppTitle.ts'; import { useAppTitle } from '@/modules/navigation-bar/hooks/useAppTitle.ts';
import { useAppAction } from '@/modules/navigation-bar/hooks/useAppAction.ts'; import { useAppAction } from '@/modules/navigation-bar/hooks/useAppAction.ts';
import { ChapterDownloadStatus } from '@/modules/chapter/Chapter.types.ts'; import { ChapterDownloadStatus } from '@/modules/chapter/Chapter.types.ts';
import { VirtuosoPersisted } from '@/lib/virtuoso/Component/VirtuosoPersisted.tsx';
export const DownloadQueue: React.FC = () => { export const DownloadQueue: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -159,7 +159,8 @@ export const DownloadQueue: React.FC = () => {
onDragAbort={() => setDndActiveDownload(null)} onDragAbort={() => setDndActiveDownload(null)}
> >
<SortableContext items={dndItems} strategy={verticalListSortingStrategy}> <SortableContext items={dndItems} strategy={verticalListSortingStrategy}>
<Virtuoso <VirtuosoPersisted
persistKey="download-queue"
useWindowScroll useWindowScroll
overscan={window.innerHeight * 0.5} overscan={window.innerHeight * 0.5}
totalCount={queue.length} totalCount={queue.length}

View File

@@ -290,6 +290,7 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) {
return ( return (
<> <>
<StyledGroupedVirtuoso <StyledGroupedVirtuoso
persistKey="extensions"
heightToSubtract={tabsMenuHeight} heightToSubtract={tabsMenuHeight}
overscan={window.innerHeight * 0.5} overscan={window.innerHeight * 0.5}
groupCounts={groupCounts} groupCounts={groupCounts}

View File

@@ -79,6 +79,7 @@ export const History: React.FC = () => {
return ( return (
<StyledGroupedVirtuoso <StyledGroupedVirtuoso
persistKey="history"
components={{ components={{
Footer: () => (isLoading ? <LoadingPlaceholder usePadding /> : null), Footer: () => (isLoading ? <LoadingPlaceholder usePadding /> : null),
}} }}

View File

@@ -140,6 +140,7 @@ export const LibraryDuplicates = () => {
if (gridLayout === GridLayout.List) { if (gridLayout === GridLayout.List) {
return ( return (
<StyledGroupedVirtuoso <StyledGroupedVirtuoso
persistKey="library-duplicates"
groupCounts={mangasCountByTitle} groupCounts={mangasCountByTitle}
groupContent={(index) => ( groupContent={(index) => (
<StyledGroupHeader isFirstItem={index === 0}> <StyledGroupHeader isFirstItem={index === 0}>

View File

@@ -10,7 +10,6 @@ import React, {
ForwardedRef, ForwardedRef,
forwardRef, forwardRef,
useCallback, useCallback,
useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
@@ -19,22 +18,20 @@ import React, {
} from 'react'; } from 'react';
import Grid, { GridTypeMap } from '@mui/material/Grid'; import Grid, { GridTypeMap } from '@mui/material/Grid';
import Box, { BoxProps } from '@mui/material/Box'; import Box, { BoxProps } from '@mui/material/Box';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; import { GridItemProps } from 'react-virtuoso';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.tsx'; import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.tsx';
import { MangaCard } from '@/modules/manga/components/cards/MangaCard.tsx'; import { MangaCard } from '@/modules/manga/components/cards/MangaCard.tsx';
import { useSessionStorage } from '@/modules/core/hooks/useStorage.tsx';
import { SelectableCollectionReturnType } from '@/modules/collection/hooks/useSelectableCollection.ts'; import { SelectableCollectionReturnType } from '@/modules/collection/hooks/useSelectableCollection.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/modules/core/components/buttons/StyledFab.tsx'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/modules/core/components/buttons/StyledFab.tsx';
import { AppStorage } from '@/lib/storage/AppStorage.ts';
import { MangaCardProps } from '@/modules/manga/Manga.types.ts'; import { MangaCardProps } from '@/modules/manga/Manga.types.ts';
import { MangaType } from '@/lib/graphql/generated/graphql.ts'; import { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx'; import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { GridLayout } from '@/modules/core/Core.types.ts'; import { GridLayout } from '@/modules/core/Core.types.ts';
import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts'; import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts';
import { VirtuosoGridPersisted } from '@/lib/virtuoso/Component/VirtuosoGridPersisted.tsx';
const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => ( const GridContainer = React.forwardRef<HTMLDivElement, GridTypeMap['props']>(({ children, ...props }, ref) => (
<Grid {...props} ref={ref} container spacing={1}> <Grid {...props} ref={ref} container spacing={1}>
@@ -138,8 +135,7 @@ const HorizontalGrid = forwardRef(
), ),
); );
export const getGridSnapshotKey = (location: ReturnType<typeof useLocation>) => export const MANGA_GRID_SNAPSHOT_KEY = 'MangaGrid-snapshot-location';
`MangaGrid-snapshot-location-${location.key}`;
const VerticalGrid = forwardRef( const VerticalGrid = forwardRef(
( (
@@ -160,69 +156,45 @@ const VerticalGrid = forwardRef(
loadMore: () => void; loadMore: () => void;
}, },
ref: ForwardedRef<HTMLDivElement | null>, ref: ForwardedRef<HTMLDivElement | null>,
) => { ) => (
const location = useLocation<{ snapshot?: GridStateSnapshot }>(); <>
<Box ref={ref}>
const snapshotSessionKey = getGridSnapshotKey(location); <VirtuosoGridPersisted
const [snapshot] = useSessionStorage<GridStateSnapshot | undefined>(snapshotSessionKey, undefined); persistKey={MANGA_GRID_SNAPSHOT_KEY}
useWindowScroll
const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>(undefined); increaseViewportBy={window.innerHeight * 0.5}
const persistGridState = (gridState: GridStateSnapshot) => { totalCount={mangas.length}
const currentUrl = window.location.href; components={{
List: GridContainer,
clearTimeout(persistGridStateTimeout.current); Item: GridItemContainer,
persistGridStateTimeout.current = setTimeout(() => { }}
const didLocationChange = currentUrl !== window.location.href; endReached={() => loadMore()}
if (didLocationChange) { computeItemKey={(index) => mangas[index].id}
return; itemContent={(index) =>
} createMangaCard(
mangas[index],
AppStorage.session.setItem(snapshotSessionKey, gridState, false); gridLayout,
}, 250); inLibraryIndicator,
}; isSelectModeActive,
useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]); selectedMangaIds,
handleSelection,
return ( mode,
<> )
<Box ref={ref}> }
<VirtuosoGrid />
useWindowScroll </Box>
increaseViewportBy={window.innerHeight * 0.5} {/* render div to prevent UI jumping around when showing/hiding loading placeholder */
totalCount={mangas.length} /* eslint-disable-next-line no-nested-ternary */}
components={{ {isSelectModeActive && gridLayout === GridLayout.List ? (
List: GridContainer, <Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
Item: GridItemContainer, ) : // eslint-disable-next-line no-nested-ternary
}} isLoading ? (
restoreStateFrom={snapshot} <LoadingPlaceholder />
stateChanged={persistGridState} ) : hasNextPage ? (
endReached={() => loadMore()} <div style={{ height: '75px' }} />
computeItemKey={(index) => mangas[index].id} ) : null}
itemContent={(index) => </>
createMangaCard( ),
mangas[index],
gridLayout,
inLibraryIndicator,
isSelectModeActive,
selectedMangaIds,
handleSelection,
mode,
)
}
/>
</Box>
{/* render div to prevent UI jumping around when showing/hiding loading placeholder */
/* eslint-disable-next-line no-nested-ternary */}
{isSelectModeActive && gridLayout === GridLayout.List ? (
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }} />
) : // eslint-disable-next-line no-nested-ternary
isLoading ? (
<LoadingPlaceholder />
) : hasNextPage ? (
<div style={{ height: '75px' }} />
) : null}
</>
);
},
); );
export interface IMangaGridProps export interface IMangaGridProps

View File

@@ -40,8 +40,7 @@ import {
useMetadataServerSettings, useMetadataServerSettings,
} from '@/modules/settings/services/ServerSettingsMetadata.ts'; } from '@/modules/settings/services/ServerSettingsMetadata.ts';
import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx'; import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx';
import { AppStorage } from '@/lib/storage/AppStorage.ts'; import { MANGA_GRID_SNAPSHOT_KEY } from '@/modules/manga/components/MangaGrid.tsx';
import { getGridSnapshotKey } from '@/modules/manga/components/MangaGrid.tsx';
import { createUpdateSourceMetadata, useGetSourceMetadata } from '@/modules/source/services/SourceMetadata.ts'; import { createUpdateSourceMetadata, useGetSourceMetadata } from '@/modules/source/services/SourceMetadata.ts';
import { makeToast } from '@/modules/core/utils/Toast.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts';
import { GET_SOURCE_BROWSE } from '@/lib/graphql/queries/SourceQuery.ts'; import { GET_SOURCE_BROWSE } from '@/lib/graphql/queries/SourceQuery.ts';
@@ -57,6 +56,7 @@ import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { Sources } from '@/modules/source/services/Sources.ts'; import { Sources } from '@/modules/source/services/Sources.ts';
import { useAppTitleAndAction } from '@/modules/navigation-bar/hooks/useAppTitleAndAction.ts'; import { useAppTitleAndAction } from '@/modules/navigation-bar/hooks/useAppTitleAndAction.ts';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
const DEFAULT_SOURCE: SourceIdInfo = { id: '-1' }; const DEFAULT_SOURCE: SourceIdInfo = { id: '-1' };
@@ -246,10 +246,12 @@ export function SourceMangas() {
query ? SourceContentType.SEARCH : currentContentType!, query ? SourceContentType.SEARCH : currentContentType!,
); );
const { key: persistedGridStateKey, deleteState: deletePersistedGridState } =
VirtuosoUtil.usePersistState(MANGA_GRID_SNAPSHOT_KEY);
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
AppStorage.session.setItem(getGridSnapshotKey(location), undefined, false); deletePersistedGridState();
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, [locationKey]); }, [persistedGridStateKey]);
const currentQuery = useRef(query); const currentQuery = useRef(query);
const currentAbortRequest = useRef<(reason: any) => void>(() => {}); const currentAbortRequest = useRef<(reason: any) => void>(() => {});

View File

@@ -114,6 +114,7 @@ export const Updates: React.FC = () => {
})} })}
</Typography> </Typography>
<StyledGroupedVirtuoso <StyledGroupedVirtuoso
persistKey="updates"
heightToSubtract={lastUpdateTimestampCompHeight} heightToSubtract={lastUpdateTimestampCompHeight}
components={{ components={{
Footer: () => (isLoading ? <LoadingPlaceholder usePadding /> : null), Footer: () => (isLoading ? <LoadingPlaceholder usePadding /> : null),