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/.
*/
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 {
static readonly GROUP = 0;
@@ -112,4 +115,46 @@ export class VirtuosoUtil {
[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 Typography from '@mui/material/Typography';
import { ComponentProps, useCallback, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useTranslation } from 'react-i18next';
import { requestManager } from '@/lib/requests/RequestManager.ts';
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 { makeToast } from '@/modules/core/utils/Toast.ts';
import { ChapterListCard } from '@/modules/chapter/components/cards/ChapterListCard.tsx';
import { VirtuosoPersisted } from '@/lib/virtuoso/Component/VirtuosoPersisted.tsx';
type ChapterListHeaderProps = {
scrollbarWidth: number;
@@ -61,7 +61,7 @@ const ChapterListHeader = styled(Stack, {
}));
type StyledVirtuosoProps = { topOffset: number };
const StyledVirtuoso = styled(Virtuoso, {
const StyledVirtuoso = styled(VirtuosoPersisted, {
shouldForwardProp: shouldForwardProp<StyledVirtuosoProps>(['topOffset']),
})<StyledVirtuosoProps>(({ theme, topOffset }) => ({
listStyle: 'none',
@@ -234,6 +234,7 @@ export const ChapterList = ({
)}
<StyledVirtuoso
persistKey={`manga-${manga.id}-chapter-list`}
topOffset={appBarHeight + chapterListHeaderHeight}
style={{
// 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/.
*/
import { ContextProp, GroupedVirtuoso, TopItemListProps } from 'react-virtuoso';
import { ContextProp, TopItemListProps } from 'react-virtuoso';
import { ComponentProps, useMemo } from 'react';
import Box from '@mui/material/Box';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { GroupedVirtuosoPersisted } from '@/lib/virtuoso/Component/GroupedVirtuosoPersisted.tsx';
const StickyVirtuosoHeaderWithOffset =
(topOffset: number) =>
@@ -23,7 +24,7 @@ export const StyledGroupedVirtuoso = ({
heightToSubtract = 0,
style,
...props
}: ComponentProps<typeof GroupedVirtuoso> & { heightToSubtract?: number }) => {
}: ComponentProps<typeof GroupedVirtuosoPersisted> & { heightToSubtract?: number }) => {
const { appBarHeight, bottomBarHeight } = useNavBarContext();
const TopItemList = useMemo(
@@ -32,7 +33,7 @@ export const StyledGroupedVirtuoso = ({
);
return (
<GroupedVirtuoso
<GroupedVirtuosoPersisted
useWindowScroll
{...props}
components={{

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import React, {
ForwardedRef,
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
@@ -19,22 +18,20 @@ import React, {
} from 'react';
import Grid, { GridTypeMap } from '@mui/material/Grid';
import Box, { BoxProps } from '@mui/material/Box';
import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
import { useLocation } from 'react-router-dom';
import { GridItemProps } from 'react-virtuoso';
import { useTranslation } from 'react-i18next';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.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 { 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 { MangaType } from '@/lib/graphql/generated/graphql.ts';
import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { GridLayout } from '@/modules/core/Core.types.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) => (
<Grid {...props} ref={ref} container spacing={1}>
@@ -138,8 +135,7 @@ const HorizontalGrid = forwardRef(
),
);
export const getGridSnapshotKey = (location: ReturnType<typeof useLocation>) =>
`MangaGrid-snapshot-location-${location.key}`;
export const MANGA_GRID_SNAPSHOT_KEY = 'MangaGrid-snapshot-location';
const VerticalGrid = forwardRef(
(
@@ -160,69 +156,45 @@ const VerticalGrid = forwardRef(
loadMore: () => void;
},
ref: ForwardedRef<HTMLDivElement | null>,
) => {
const location = useLocation<{ snapshot?: GridStateSnapshot }>();
const snapshotSessionKey = getGridSnapshotKey(location);
const [snapshot] = useSessionStorage<GridStateSnapshot | undefined>(snapshotSessionKey, undefined);
const persistGridStateTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const persistGridState = (gridState: GridStateSnapshot) => {
const currentUrl = window.location.href;
clearTimeout(persistGridStateTimeout.current);
persistGridStateTimeout.current = setTimeout(() => {
const didLocationChange = currentUrl !== window.location.href;
if (didLocationChange) {
return;
}
AppStorage.session.setItem(snapshotSessionKey, gridState, false);
}, 250);
};
useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]);
return (
<>
<Box ref={ref}>
<VirtuosoGrid
useWindowScroll
increaseViewportBy={window.innerHeight * 0.5}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
restoreStateFrom={snapshot}
stateChanged={persistGridState}
endReached={() => loadMore()}
computeItemKey={(index) => mangas[index].id}
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}
</>
);
},
) => (
<>
<Box ref={ref}>
<VirtuosoGridPersisted
persistKey={MANGA_GRID_SNAPSHOT_KEY}
useWindowScroll
increaseViewportBy={window.innerHeight * 0.5}
totalCount={mangas.length}
components={{
List: GridContainer,
Item: GridItemContainer,
}}
endReached={() => loadMore()}
computeItemKey={(index) => mangas[index].id}
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

View File

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

View File

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