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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user