perf(library): virtualize grid and list of book items when rendering library page (#3835)

This commit is contained in:
Huang Xin 2026-04-12 03:25:06 +08:00 committed by GitHub
parent 20940105fb
commit f86bbbcc22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 204 additions and 58 deletions

View file

@ -63,8 +63,8 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'bookitem-main relative flex aspect-[28/41] justify-center rounded',
coverFit === 'crop' && 'overflow-hidden shadow-md',
'bookitem-main relative flex aspect-[28/41] justify-center overflow-hidden rounded',
coverFit === 'crop' && 'shadow-md',
mode === 'grid' && 'items-end',
mode === 'list' && 'min-w-20 items-center',
)}

View file

@ -3,6 +3,14 @@ import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiPlus } from 'react-icons/pi';
import {
Virtuoso,
VirtuosoGrid,
type Components,
type GridComponents,
type GridListProps,
type ListProps,
} from 'react-virtuoso';
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import {
LibraryCoverFitType,
@ -46,6 +54,13 @@ interface BookshelfProps {
isSelectMode: boolean;
isSelectAll: boolean;
isSelectNone: boolean;
/**
* The DOM element whose `overflow-y: auto` backs the bookshelf scroll.
* Passed to react-virtuoso as `customScrollParent` so virtualization uses
* the parent page scroller (and existing pull-to-refresh / scroll save /
* restore logic keeps working).
*/
scrollParentEl: HTMLDivElement | null;
handleImportBooks: () => 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<BookshelfListContext>['List'] = React.forwardRef<
HTMLDivElement,
GridListProps & { context?: BookshelfListContext }
>(({ children, className, style, context, 'data-testid': testId }, ref) => (
<div
ref={ref}
data-testid={testId}
className={clsx(BOOKSHELF_GRID_CLASSES, className)}
style={{
...style,
gridTemplateColumns:
context && !context.autoColumns
? `repeat(${context.fixedColumns}, minmax(0, 1fr))`
: undefined,
}}
>
{children}
</div>
));
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<HTMLDivElement, ListProps>(
({ children, style, 'data-testid': testId }, ref) => (
<div ref={ref} data-testid={testId} className={BOOKSHELF_LIST_CLASSES} style={style}>
{children}
</div>
),
);
BookshelfLinearList.displayName = 'BookshelfLinearList';
const GRID_VIRTUOSO_COMPONENTS: GridComponents<BookshelfListContext> = {
List: BookshelfGridList,
};
const LIST_VIRTUOSO_COMPONENTS: Components = {
List: BookshelfLinearList,
};
const Bookshelf: React.FC<BookshelfProps> = ({
libraryBooks,
isSelectMode,
isSelectAll,
isSelectNone,
scrollParentEl,
handleImportBooks,
handleBookUpload,
handleBookDownload,
@ -396,61 +476,30 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}, []);
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 (
<div className='bookshelf'>
<div
ref={autofocusRef}
tabIndex={-1}
className={clsx(
'bookshelf-items transform-wrapper focus:outline-none',
viewMode === 'grid' && 'grid flex-1 grid-cols-3 gap-x-4 px-4 sm:gap-x-0 sm:px-2',
viewMode === 'grid' && 'sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
viewMode === 'list' && 'flex flex-col',
)}
style={{
gridTemplateColumns:
viewMode === 'grid' && !settings.libraryAutoColumns
? `repeat(${settings.libraryColumns}, minmax(0, 1fr))`
: undefined,
}}
role='main'
aria-label={_('Bookshelf')}
>
{sortedBookshelfItems.map((item) => (
<BookshelfItem
key={`library-item-${'hash' in item ? item.hash : item.id}`}
item={item}
mode={viewMode as LibraryViewModeType}
coverFit={coverFit as LibraryCoverFitType}
isSelectMode={isSelectMode}
itemSelected={
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id)
}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleGroupBooks={groupSelectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
handleUpdateReadingStatus={handleUpdateReadingStatus}
transferProgress={
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
}
/>
))}
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
const listContext = useMemo<BookshelfListContext>(
() => ({
autoColumns: settings.libraryAutoColumns,
fixedColumns: settings.libraryColumns,
}),
[settings.libraryAutoColumns, settings.libraryColumns],
);
const renderBookshelfItem = useCallback(
(index: number) => {
if (isGridMode && index === sortedBookshelfItems.length) {
return (
<div
className={clsx('bookshelf-import-item mx-0 my-2 sm:mx-4 sm:my-4')}
style={
coverFit === 'fit' && viewMode === 'grid'
? {
display: 'flex',
paddingBottom: `${iconSize15 + 24}px`,
}
coverFit === 'fit'
? { display: 'flex', paddingBottom: `${iconSize15 + 24}px` }
: undefined
}
>
@ -468,8 +517,96 @@ const Bookshelf: React.FC<BookshelfProps> = ({
</div>
</button>
</div>
)}
</div>
);
}
const item = sortedBookshelfItems[index];
if (!item) return null;
const itemSelected =
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id);
return (
<BookshelfItem
item={item}
mode={viewMode as LibraryViewModeType}
coverFit={coverFit as LibraryCoverFitType}
isSelectMode={isSelectMode}
itemSelected={itemSelected}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleGroupBooks={groupSelectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
handleUpdateReadingStatus={handleUpdateReadingStatus}
transferProgress={
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
}
/>
);
},
// 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 (
<div
ref={autofocusRef}
tabIndex={-1}
role='main'
aria-label={_('Bookshelf')}
className='bookshelf focus:outline-none'
>
{scrollParentEl && hasItems && isGridMode && (
<VirtuosoGrid<unknown, BookshelfListContext>
customScrollParent={scrollParentEl}
totalCount={gridTotalCount}
components={GRID_VIRTUOSO_COMPONENTS}
context={listContext}
computeItemKey={computeItemKey}
itemContent={renderBookshelfItem}
/>
)}
{scrollParentEl && hasItems && !isGridMode && (
<Virtuoso
customScrollParent={scrollParentEl}
totalCount={sortedBookshelfItems.length}
components={LIST_VIRTUOSO_COMPONENTS}
computeItemKey={computeItemKey}
itemContent={renderBookshelfItem}
/>
)}
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />

View file

@ -388,7 +388,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
};
return (
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
<div
className={clsx(
'visible-focus-inset-2 group',

View file

@ -134,6 +134,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const viewSettings = settings.globalViewSettings;
const demoBooks = useDemoBooks();
const scrollRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
const attachScrollRef = useCallback((el: HTMLDivElement | null) => {
scrollRef.current = el;
setScrollEl(el);
}, []);
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
const pageRef = useRef<HTMLDivElement>(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) ? (
<div
ref={scrollRef}
ref={attachScrollRef}
aria-label={_('Your Bookshelf')}
className='library-scroller flex-grow'
>
@ -962,6 +970,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
scrollParentEl={scrollEl}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}