perf: debounce price-range/slider filter inputs

updateRange()/updateSlider() emitted stateChange synchronously on
every keystroke/drag event, triggering a full catalog filter
recompute each time. Debounced both (350ms, per filterId+key timer,
cleared on destroy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-13 11:07:03 +04:00
parent c9a80da7c3
commit a339a1c64e

View File

@@ -1,8 +1,10 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model'; import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
const RANGE_DEBOUNCE_MS = 350;
@Component({ @Component({
selector: 'app-catalog-filters-panel', selector: 'app-catalog-filters-panel',
standalone: true, standalone: true,
@@ -11,12 +13,20 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
styleUrls: ['./filters-panel.component.scss'], styleUrls: ['./filters-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CatalogFiltersPanelComponent { export class CatalogFiltersPanelComponent implements OnDestroy {
@Input() definitions: FilterGroup[] = []; @Input() definitions: FilterGroup[] = [];
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} }; @Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
@Output() stateChange = new EventEmitter<SearchFilterState>(); @Output() stateChange = new EventEmitter<SearchFilterState>();
private readonly debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
ngOnDestroy(): void {
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
}
readonly collapsed = signal<Record<string, boolean>>({}); readonly collapsed = signal<Record<string, boolean>>({});
toggleGroup(filterId: string): void { toggleGroup(filterId: string): void {
@@ -79,34 +89,51 @@ export class CatalogFiltersPanelComponent {
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void { updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
const value = rawValue.trim().length ? Number(rawValue) : undefined; const value = rawValue.trim().length ? Number(rawValue) : undefined;
const current = this.state.ranges[filterId] ?? {};
this.stateChange.emit({ this.debounce(`range:${filterId}:${key}`, () => {
...this.state, const current = this.state.ranges[filterId] ?? {};
ranges: { this.stateChange.emit({
...this.state.ranges, ...this.state,
[filterId]: { ranges: {
...current, ...this.state.ranges,
[key]: Number.isFinite(value as number) ? value : undefined [filterId]: {
...current,
[key]: Number.isFinite(value as number) ? value : undefined
}
} }
} });
}); });
} }
updateSlider(filterId: string, value: string): void { updateSlider(filterId: string, value: string): void {
const numeric = Number(value); const numeric = Number(value);
this.stateChange.emit({
...this.state, this.debounce(`slider:${filterId}`, () => {
ranges: { this.stateChange.emit({
...this.state.ranges, ...this.state,
[filterId]: { ranges: {
...this.state.ranges[filterId], ...this.state.ranges,
max: Number.isFinite(numeric) ? numeric : undefined, [filterId]: {
...this.state.ranges[filterId],
max: Number.isFinite(numeric) ? numeric : undefined,
},
}, },
}, });
}); });
} }
/** Debounces range/slider input so full catalog filter recompute doesn't run on every keystroke/drag event. */
private debounce(key: string, action: () => void): void {
const existing = this.debounceTimers.get(key);
if (existing) {
clearTimeout(existing);
}
this.debounceTimers.set(key, setTimeout(() => {
this.debounceTimers.delete(key);
action();
}, RANGE_DEBOUNCE_MS));
}
updateToggle(filterId: string, checked: boolean): void { updateToggle(filterId: string, checked: boolean): void {
this.stateChange.emit({ this.stateChange.emit({
...this.state, ...this.state,