refactor: reduce re-renders and improve filter sidebar performance (#2440)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-01-23 17:34:33 -07:00
committed by GitHub
parent bb0ee8373e
commit 18419c970f
3 changed files with 52 additions and 58 deletions

View File

@@ -13,12 +13,12 @@
</div>
<div class="filter-content">
<p-accordion [value]="expandedPanels" [multiple]="true">
<p-accordion [value]="expandedPanels" [multiple]="true" (valueChange)="onExpandedPanelsChange($event)">
@for (filterType of filterTypes; track trackByFilterType(i, filterType); let i = $index) {
@if (filterStreams[filterType] | async; as filters) {
@if (filters.length > 0) {
<p-accordion-panel [value]="i">
<p-accordion-header>
<p-accordion-panel [value]="i">
<p-accordion-header>
<span class="filter-type-label">
{{ filterLabels[filterType] || (filterType | titlecase) }}
@if (activeFilters[filterType]?.length) {
@@ -27,29 +27,33 @@
</span>
}
</span>
</p-accordion-header>
</p-accordion-header>
<p-accordion-content>
<div class="filter-list">
@for (filter of filters; track trackByFilter(j, filter); let j = $index) {
<div
class="filter-row"
[ngClass]="{
<p-accordion-content>
@if (expandedPanels.includes(i)) {
<cdk-virtual-scroll-viewport
[itemSize]="28"
[style.height.px]="getVirtualScrollHeight(filters.length)"
class="filter-list">
<div
*cdkVirtualFor="let filter of filters; trackBy: trackByFilter"
class="filter-row"
[ngClass]="{
'active': activeFilters[filterType]?.includes(getFilterValueId(filter))
}"
(click)="handleFilterClick(filterType, getFilterValueId(filter))">
{{ getFilterValueDisplay(filter) }}
<p-badge class="filter-value-badge" [value]="filter.bookCount"></p-badge>
</div>
}
@if (truncatedFilters[filterType]) {
<div class="truncation-notice">
Showing first 500 items
</div>
}
</div>
</p-accordion-content>
</p-accordion-panel>
(click)="handleFilterClick(filterType, getFilterValueId(filter))">
{{ getFilterValueDisplay(filter) }}
<p-badge class="filter-value-badge" [value]="filter.bookCount"></p-badge>
</div>
</cdk-virtual-scroll-viewport>
@if (truncatedFilters[filterType]) {
<div class="truncation-notice">
Showing first 500 items
</div>
}
}
</p-accordion-content>
</p-accordion-panel>
}
}
}

View File

@@ -51,16 +51,13 @@
}
.filter-list {
max-height: 27.5rem;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 0.25rem;
}
.filter-row {
cursor: pointer;
transition: colors 200ms ease-in-out;
padding-bottom: 0.25rem;
height: 26px;
display: flex;
flex-direction: row;
align-items: center;

View File

@@ -7,6 +7,7 @@ import {Shelf} from '../../../model/shelf.model';
import {EntityType} from '../book-browser.component';
import {Book, ReadStatus} from '../../../model/book.model';
import {Accordion, AccordionContent, AccordionHeader, AccordionPanel} from 'primeng/accordion';
import {CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common';
import {Badge} from 'primeng/badge';
import {FormsModule} from '@angular/forms';
@@ -28,8 +29,6 @@ export interface Filter<T extends FilterValue = FilterValue> {
}
export type FilterType =
| 'library'
| 'shelf'
| 'author'
| 'category'
| 'series'
@@ -42,13 +41,10 @@ export type FilterType =
| 'tag'
| 'language'
| 'bookType'
| 'shelfStatus'
| 'fileSize'
| 'pageCount'
| 'amazonRating'
| 'goodreadsRating'
| 'ranobedbRating'
| 'hardcoverRating';
| 'goodreadsRating';
export const ratingRanges = [
{id: '0to1', label: '0 to 1', min: 0, max: 1, sortIndex: 0},
@@ -176,6 +172,9 @@ function getReadStatusName(status?: ReadStatus | null): string {
AccordionPanel,
AccordionHeader,
AccordionContent,
CdkVirtualScrollViewport,
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
NgClass,
Badge,
AsyncPipe,
@@ -207,8 +206,6 @@ export class BookFilterComponent implements OnInit, OnDestroy {
private _selectedFilterMode: BookFilterMode = 'and';
expandedPanels: number[] = [0];
readonly filterLabels: Record<FilterType, string> = {
library: 'Library',
shelf: 'Shelf',
author: 'Author',
category: 'Genre',
series: 'Series',
@@ -221,13 +218,10 @@ export class BookFilterComponent implements OnInit, OnDestroy {
tag: 'Tag',
language: 'Language',
bookType: 'Book Type',
shelfStatus: 'Shelf Status',
fileSize: 'File Size',
pageCount: 'Page Count',
amazonRating: 'Amazon Rating',
goodreadsRating: 'Goodreads Rating',
hardcoverRating: 'Hardcover Rating',
ranobedbRating: 'Ranobedb Rating',
goodreadsRating: 'Goodreads Rating'
};
private destroy$ = new Subject<void>();
@@ -253,14 +247,6 @@ export class BookFilterComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.filterStreams = {
library: this.getFilterStream(
(book) => (book.libraryId ? [{id: book.libraryId, name: book.libraryName}] : []),
'id', 'name'
),
shelf: this.getFilterStream(
(book) => (book.shelves ? book.shelves.map(s => ({id: s.id, name: s.name})) : []),
'id', 'name'
),
author: this.getFilterStream(
(book: Book) => Array.isArray(book.metadata?.authors) ? book.metadata.authors.map(name => ({id: name, name})) : [],
'id', 'name'
@@ -297,13 +283,10 @@ export class BookFilterComponent implements OnInit, OnDestroy {
),
language: this.getFilterStream(getLanguageFilter, 'id', 'name'),
bookType: this.getFilterStream(getBookTypeFilter, 'id', 'name'),
shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name'),
fileSize: this.getFilterStream((book: Book) => getFileSizeRangeFilters(book.fileSizeKb), 'id', 'name', 'sortIndex'),
pageCount: this.getFilterStream((book: Book) => getPageCountRangeFilters(book.metadata?.pageCount ?? undefined), 'id', 'name', 'sortIndex'),
amazonRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.amazonRating ?? undefined), 'id', 'name', 'sortIndex'),
goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating ?? undefined), 'id', 'name', 'sortIndex'),
hardcoverRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.hardcoverRating ?? undefined), 'id', 'name', 'sortIndex'),
ranobedbRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.ranobedbRating ?? undefined), 'id', 'name', 'sortIndex'),
goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating ?? undefined), 'id', 'name', 'sortIndex')
};
this.filterTypes = Object.keys(this.filterStreams) as FilterType[];
@@ -460,18 +443,28 @@ export class BookFilterComponent implements OnInit, OnDestroy {
clearActiveFilter() {
this.activeFilters = {};
this.setExpandedPanels();
this.expandedPanels = [0];
this.filterChangeSubject.next(null);
}
onExpandedPanelsChange(value: string | number | string[] | number[] | null | undefined): void {
if (Array.isArray(value)) {
this.expandedPanels = value.map(v => Number(v));
}
}
getVirtualScrollHeight(itemCount: number): number {
return Math.min(itemCount * 28, 440);
}
setExpandedPanels(): void {
const indexes = [];
const current = new Set(this.expandedPanels);
for (let i = 0; i < this.filterTypes.length; i++) {
if (this.activeFilters[this.filterTypes[i]]?.length) {
indexes.push(i);
current.add(i);
}
}
this.expandedPanels = indexes.length > 0 ? indexes : [0];
this.expandedPanels = current.size > 0 ? [...current] : [0];
}
onFiltersChanged(): void {
@@ -484,18 +477,18 @@ export class BookFilterComponent implements OnInit, OnDestroy {
trackByFilter(_: number, filter: Filter<FilterValue>): unknown {
const value = filter.value as { id?: unknown } | unknown;
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as {id: unknown}).id : filter.value;
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as { id: unknown }).id : filter.value;
}
getFilterValueId(filter: Filter<FilterValue>): unknown {
const value = filter.value as { id?: unknown } | unknown;
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as {id: unknown}).id : filter.value;
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as { id: unknown }).id : filter.value;
}
getFilterValueDisplay(filter: Filter<FilterValue>): string {
const value = filter.value as { name?: string } | string | unknown;
if (typeof value === 'object' && value !== null && 'name' in value) {
return String((value as {name: string}).name ?? '');
return String((value as { name: string }).name ?? '');
}
return String(value ?? '');
}