From c477911e25d6b257053bc08f3b47da904e949ac5 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 12 Oct 2024 18:41:42 +0200 Subject: [PATCH] Properly set virtuoso item keys --- src/lib/virtuoso/Virtuoso.util.tsx | 111 ++++++++++++++++++ .../chapter/components/ChapterList.tsx | 1 + .../downloads/screens/DownloadQueue.tsx | 7 +- src/modules/extension/screens/Extensions.tsx | 12 +- .../library/screens/LibraryDuplicates.tsx | 15 ++- src/modules/manga/components/MangaGrid.tsx | 2 +- src/modules/updates/screens/Updates.tsx | 10 +- 7 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 src/lib/virtuoso/Virtuoso.util.tsx diff --git a/src/lib/virtuoso/Virtuoso.util.tsx b/src/lib/virtuoso/Virtuoso.util.tsx new file mode 100644 index 00000000..bae9fcb4 --- /dev/null +++ b/src/lib/virtuoso/Virtuoso.util.tsx @@ -0,0 +1,111 @@ +/* + * 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 { useCallback, useMemo } from 'react'; + +export class VirtuosoUtil { + /** + * Returns the index converted to the index of the list of group or normal items. + * + * @example + * + * index 1 + * groupCounts [1, 3, 1] + * + * (GCS) groups content size 2 3 1 + * (SOG) size of group (with header item) 3 7 9 + * + * is group header item: SOG - index === GCS + 1 + * is normal item : SOG - index < GCS + 1 + * + * converted index: + * is group header + * ? group index + * : index - (group index + 1) + * + * group index 0 + * 3 - 0 = 3 (group - index: 0) + * 3 - 1 = 2 (normal - index: 0 = 1 - (0 + 1)) + * 3 - 2 = 1 (normal - index: 1 = 2 - (0 + 1)) + * + * group index 1 + * 7 - 3 = 4 (group - index: 1) + * 7 - 4 = 3 (normal - index: 2 = 4 - (1 + 1)) + * 7 - 5 = 2 (normal - index: 3 = 5 - (1 + 1)) + * 7 - 6 = 1 (normal - index: 4 = 6 - (1 + 1)) + * + * group index 2 + * 9 - 7 = 2 (group - index: 2) + * 9 - 8 = 1 (normal - index: 5 = 8 - (2 + 1)) + */ + static convertIndex( + index: number, + groupCounts: number[], + sizeOfGroups: number[], + ): { type: 'normal' | 'group'; index: number; groupIndex: number } { + for (let groupIndex = 0; groupIndex < groupCounts.length; groupIndex++) { + const groupCount = groupCounts[groupIndex]; + const sizeOfGroup = sizeOfGroups[groupIndex]; + + const isIndexOfGroup = index <= sizeOfGroup - 1; + if (isIndexOfGroup) { + const isGroupHeaderItem = sizeOfGroup - index === groupCount + 1; + + return { + type: isGroupHeaderItem ? 'group' : 'normal', + index: isGroupHeaderItem ? groupIndex : index - (groupIndex + 1), + groupIndex, + }; + } + } + + throw new Error(`Unexpected "${index}" (${index}) and "groupCounts" (${groupCounts})`); + } + + static useCreateConvertIndex( + groupCounts: number[], + ): (index: number) => ReturnType { + const sizeOfGroups = useMemo(() => { + const tmpSizeOfGroups: number[] = []; + for (let i = 0; i < groupCounts.length; i++) { + const maxIndexOfPreviousGroup = tmpSizeOfGroups[i - 1] ?? 0; + tmpSizeOfGroups[i] = groupCounts[i] + 1 + maxIndexOfPreviousGroup; + } + + return tmpSizeOfGroups; + }, [groupCounts]); + + return useCallback((index: number) => this.convertIndex(index, groupCounts, sizeOfGroups), [sizeOfGroups]); + } + + static useCreateGroupedComputeItemKey( + groupCounts: number[], + getGroupKey: (index: number, groupIndex: number) => React.Key, + getNormalKey: (index: number, groupIndex: number) => React.Key, + ): (index: number) => React.Key { + const convertIndex = this.useCreateConvertIndex(groupCounts); + + return useCallback( + (index) => { + const { type, index: convertedIndex, groupIndex } = convertIndex(index); + + switch (type) { + case 'group': + return getGroupKey(convertedIndex, groupIndex); + case 'normal': + return getNormalKey(convertedIndex, groupIndex); + default: + throw new Error( + `VirtuosoUtil::useCreateGroupedComputeItemKey: unexpected "converted index type" (${type})`, + ); + } + }, + [convertIndex, getGroupKey, getNormalKey], + ); + } +} diff --git a/src/modules/chapter/components/ChapterList.tsx b/src/modules/chapter/components/ChapterList.tsx index 1ae82092..a6533d08 100644 --- a/src/modules/chapter/components/ChapterList.tsx +++ b/src/modules/chapter/components/ChapterList.tsx @@ -259,6 +259,7 @@ export const ChapterList = ({ }} components={{ Footer: () => }} totalCount={visibleChapters.length} + computeItemKey={(index) => visibleChapters[index].id} itemContent={(index: number) => ( { components={{ Item: HeightPreservingItem, }} + computeItemKey={(_, item) => item.manga.id} itemContent={(index, item) => ( - + {(draggableProvided) => ( filteredGroupedExtensions[index][0], [filteredGroupedExtensions]), + useCallback((index) => visibleExtensions[index].pkgName, [visibleExtensions]), + ); + const submitExternalExtension = (file: File) => { if (file.name.toLowerCase().endsWith('apk')) { if (inputRef.current) { @@ -298,13 +305,12 @@ export function Extensions({ tabsMenuHeight }: { tabsMenuHeight: number }) { ); }} + computeItemKey={computeItemKey} itemContent={(index) => { const item = visibleExtensions[index]; return ( - + >( libraryMangas: Manga[], @@ -180,6 +181,15 @@ export const LibraryDuplicates = () => { [mangasByTitle], ); + const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey( + mangasCountByTitle, + useCallback((index) => duplicatedTitles[index], [duplicatedTitles]), + useCallback( + (index, groupIndex) => `${duplicatedTitles[groupIndex]}-${duplicatedMangas[index].id}}`, + [duplicatedTitles, duplicatedMangas], + ), + ); + if (loading) { return ; } @@ -203,8 +213,9 @@ export const LibraryDuplicates = () => { {duplicatedTitles[index]} )} + computeItemKey={computeItemKey} itemContent={(index) => ( - + ( loadMore()} + computeItemKey={(index) => mangas[index].id} itemContent={(index) => createMangaCard( mangas[index], diff --git a/src/modules/updates/screens/Updates.tsx b/src/modules/updates/screens/Updates.tsx index 76305452..5b0c36fe 100644 --- a/src/modules/updates/screens/Updates.tsx +++ b/src/modules/updates/screens/Updates.tsx @@ -36,6 +36,7 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts' import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx'; import { ChapterIdInfo, ChapterMangaInfo } from '@/modules/chapter/services/Chapters.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts'; +import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx'; const groupByDate = (updates: Pick[]): [date: string, items: number][] => { if (!updates.length) { @@ -74,6 +75,12 @@ export const Updates: React.FC = () => { const { data: downloaderData } = requestManager.useGetDownloadStatus(); const queue = downloaderData?.downloadStatus.queue ?? []; + const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey( + groupCounts, + useCallback((index) => groupedUpdates[index][0], [groupedUpdates]), + useCallback((index) => updateEntries[index].id, [updateEntries]), + ); + const lastUpdateTimestampCompRef = useRef(null); const [lastUpdateTimestampCompHeight, setLastUpdateTimestampCompHeight] = useState(0); useLayoutEffect(() => { @@ -169,13 +176,14 @@ export const Updates: React.FC = () => { {groupedUpdates[index][0]} )} + computeItemKey={computeItemKey} itemContent={(index) => { const chapter = updateEntries[index]; const { manga } = chapter; const download = downloadForChapter(chapter); return ( - +