From f86bbbcc22dd1056d1a8747e1bb0f17f6709e64f Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 12 Apr 2026 03:25:06 +0800 Subject: [PATCH] perf(library): virtualize grid and list of book items when rendering library page (#3835) --- .../src/app/library/components/BookItem.tsx | 4 +- .../src/app/library/components/Bookshelf.tsx | 243 ++++++++++++++---- .../app/library/components/BookshelfItem.tsx | 2 +- apps/readest-app/src/app/library/page.tsx | 13 +- 4 files changed, 204 insertions(+), 58 deletions(-) diff --git a/apps/readest-app/src/app/library/components/BookItem.tsx b/apps/readest-app/src/app/library/components/BookItem.tsx index 61211744..a3d6221d 100644 --- a/apps/readest-app/src/app/library/components/BookItem.tsx +++ b/apps/readest-app/src/app/library/components/BookItem.tsx @@ -63,8 +63,8 @@ const BookItem: React.FC = ({ >
void; handleBookDownload: ( book: Book, @@ -60,11 +75,76 @@ interface BookshelfProps { booksTransferProgress: { [key: string]: number | null }; } +/** + * Context passed to the custom Virtuoso `List` components so they can render + * grid styles that depend on runtime settings without being re-created on + * every Bookshelf render (which would break Virtuoso's component identity). + */ +type BookshelfListContext = { + autoColumns: boolean; + fixedColumns: number; +}; + +const BOOKSHELF_GRID_CLASSES = + 'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' + + 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12'; + +const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col'; + +/** + * Custom List component for VirtuosoGrid. Tags the wrapping element with the + * `transform-wrapper` class that `usePullToRefresh` transforms during a pull + * gesture, and applies the runtime `libraryColumns` override when the user + * has opted out of the responsive auto grid. + */ +const BookshelfGridList: GridComponents['List'] = React.forwardRef< + HTMLDivElement, + GridListProps & { context?: BookshelfListContext } +>(({ children, className, style, context, 'data-testid': testId }, ref) => ( +
+ {children} +
+)); +BookshelfGridList.displayName = 'BookshelfGridList'; + +/** + * Custom List component for the linear Virtuoso (list view). Same purpose as + * BookshelfGridList — carries the `transform-wrapper` class for pull-to- + * refresh. + */ +const BookshelfLinearList: Components['List'] = React.forwardRef( + ({ children, style, 'data-testid': testId }, ref) => ( +
+ {children} +
+ ), +); +BookshelfLinearList.displayName = 'BookshelfLinearList'; + +const GRID_VIRTUOSO_COMPONENTS: GridComponents = { + List: BookshelfGridList, +}; +const LIST_VIRTUOSO_COMPONENTS: Components = { + List: BookshelfLinearList, +}; + const Bookshelf: React.FC = ({ libraryBooks, isSelectMode, isSelectAll, isSelectNone, + scrollParentEl, handleImportBooks, handleBookUpload, handleBookDownload, @@ -396,61 +476,30 @@ const Bookshelf: React.FC = ({ }, []); const selectedBooks = getSelectedBooks(); + const isGridMode = viewMode === 'grid'; + const hasItems = sortedBookshelfItems.length > 0; + // In grid mode the Import-Books "+" tile is rendered as an extra grid cell + // after all books. We represent it to Virtuoso as an extra index past the + // last book; list mode doesn't have an import tile. + const gridTotalCount = hasItems ? sortedBookshelfItems.length + 1 : 0; - return ( -
-
- {sortedBookshelfItems.map((item) => ( - - ))} - {viewMode === 'grid' && currentBookshelfItems.length > 0 && ( + const listContext = useMemo( + () => ({ + autoColumns: settings.libraryAutoColumns, + fixedColumns: settings.libraryColumns, + }), + [settings.libraryAutoColumns, settings.libraryColumns], + ); + + const renderBookshelfItem = useCallback( + (index: number) => { + if (isGridMode && index === sortedBookshelfItems.length) { + return (
@@ -468,8 +517,96 @@ const Bookshelf: React.FC = ({
- )} -
+ ); + } + const item = sortedBookshelfItems[index]; + if (!item) return null; + const itemSelected = + 'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id); + return ( + + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + sortedBookshelfItems, + selectedBooks, + isGridMode, + viewMode, + coverFit, + isSelectMode, + booksTransferProgress, + iconSize15, + handleImportBooks, + toggleSelection, + handleBookUpload, + handleBookDownload, + handleBookDelete, + handleSetSelectMode, + handleShowDetailsBook, + handleLibraryNavigation, + handleUpdateReadingStatus, + ], + ); + + const computeItemKey = useCallback( + (index: number) => { + if (isGridMode && index === sortedBookshelfItems.length) { + return 'library-import-tile'; + } + const item = sortedBookshelfItems[index]; + if (!item) return `library-item-${index}`; + return `library-item-${'hash' in item ? item.hash : item.id}`; + }, + [sortedBookshelfItems, isGridMode], + ); + + return ( +
+ {scrollParentEl && hasItems && isGridMode && ( + + customScrollParent={scrollParentEl} + totalCount={gridTotalCount} + components={GRID_VIRTUOSO_COMPONENTS} + context={listContext} + computeItemKey={computeItemKey} + itemContent={renderBookshelfItem} + /> + )} + {scrollParentEl && hasItems && !isGridMode && ( + + )} {loading && (
diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 5784ef9d..6fe7ba7b 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -388,7 +388,7 @@ const BookshelfItem: React.FC = ({ }; return ( -
+
(null); + // Mirror `scrollRef` in state so react-virtuoso's `customScrollParent` (which + // only reads the prop once per mount cycle) always sees a real element + // rather than `null` on the Bookshelf's first render. + const [scrollEl, setScrollEl] = useState(null); + const attachScrollRef = useCallback((el: HTMLDivElement | null) => { + scrollRef.current = el; + setScrollEl(el); + }, []); const containerRef: React.MutableRefObject = useRef(null); const pageRef = useRef(null); @@ -162,7 +170,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const { isDragging } = useDragDropImport(); usePullToRefresh( - containerRef, + scrollRef, pullLibrary.bind(null, false, true), pullLibrary.bind(null, true, true), ); @@ -942,7 +950,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP {showBookshelf && (libraryBooks.some((book) => !book.deletedAt) ? (
@@ -962,6 +970,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP isSelectMode={isSelectMode} isSelectAll={isSelectAll} isSelectNone={isSelectNone} + scrollParentEl={scrollEl} handleImportBooks={handleImportBooksFromFiles} handleBookUpload={handleBookUpload} handleBookDownload={handleBookDownload}