'Angular Mouseover Events Preventing Change Detection Updating The Template
Angular v12.2, NgRx v12.4, RxJs v6.6.0
I am developing an Angular web app and one part of it displays a data bound grid of approximately 300 squares. A little like a chess board. As the user moves the mouse over the grid I display some data relating to the individual square the mouse is hovering over. However, no matter what I try the displayed data lags behind the mouse and only updates when the mouse slows or stops. Curiously I am following a method which I successfully adopted for another project which is really performant but was using Angular v10, NgRx v10.
The grid of components is a simple *ngFor="let location of locations; let i = index; trackBy:locationTrackByFunction".
I have chosen to create a mouse-tracker component which sits above my grid and its only responsibility is to get the mouse offsetX and offsetY and emit events outside of the Angular Zone for the mouse-tracker-container parent component to receive. I did this to minimise the change detection being triggered for every change in the mousemove event. My mouse-tracker template is as follows:
<div #eventDiv id="mouse-tracker"></div>
The code is as follows:
@ViewChild('eventDiv', { static: true }) eventDiv!: ElementRef;
@Output() mouseMove = new EventEmitter();
private move$: Observable<MouseEvent> | undefined;
private leave$: Observable<MouseEvent> | undefined;
private moveSubscription: Subscription = new Subscription;
constructor(private ngZone: NgZone) {}
ngOnInit() {}
ngOnDestroy(): void {
this.moveSubscription.unsubscribe();
}
ngAfterViewInit() {
const eventElement = this.eventDiv.nativeElement;
this.move$ = fromEvent(eventElement, 'mousemove');
this.leave$ = fromEvent(eventElement, 'mouseleave');
/*
* We are going to detect mouse move events outside of
* Angular's Zone to prevent Change Detection every time
* a mouse move event is fired.
*/
this.ngZone.runOutsideAngular(() => {
// Check we have a move$ and leave$ objects.
if (this.move$ && this.leave$) {
// Configure moveSubscription.
this.moveSubscription = this.move$.pipe(
takeUntil(this.leave$),
repeat(),
).subscribe((e: MouseEvent) => {
//e.stopPropagation();
e.preventDefault();
this.mouseMove.emit(e);
});
};
});
};
The mouseMove event is received by the hosting grid component and it simply emits its own event passing the data back to its own grid-container parent as follows:
public onMouseMove(e: MouseEvent) {
this.mouseMove.emit(e);
};
The grid-container component, on receiving the mouseMove event simply updates an RxJs Subject as follows:
public onMouseMove(e: MouseEvent) {
this.mouseOffsetsSubject$.next({ x: e.offsetX, y: e.offsetY });
};
The mouseoverLocationSubject$ along with two other Observables are brought together in an RxJs CombineLatest and ultimately after performing some calculations Dispatch an NgRx Action every time the mouse is over a different location (using distinctUntilChanged()) in the Angular Zone (to implement change detection) as follows:
this.ngZone.run(() => {
if (location !== null) {
// Dispatch an NgRx Action to save the Location in the Store.
this.store.dispatch(fromCoreActions.LocationActions.setMouseoverLocationId(
{ mouseoverLocationId: location.id }
));
}
else {
// Dispatch an NgRx Action to save the Location in the Store.
this.store.dispatch(fromCoreActions.LocationActions.setMouseoverLocationId(
{ mouseoverLocationId: null }
));
};
});
If I console log out the code in the reducer, or use the Redux dev tools in Chrome, I can see the NgRx Store being rapidly updated. No problem.
A location-display component is responsible for displaying the name of the mouse-over location. Its parent location-display-container component has an Observable being updated by the changing NgRx Selector as follows:
public mouseoverLocation$: Observable<Location | null>;
...
this.mouseoverLocation$ = this.store.pipe(
select(fromCoreSelectors.locationSelectors.selectMouseoverLocation),
tap(location => {
//console.log(`mouseoverLocation$: ${location?.name}`);
})
);
If I un-comment the console.log() the changing stream of data being received is rapid and closely matches the moving mouse. No delays up to here.
A simple async pipe passes the mouseoverLocation object to the child component [mouseoverLocation]="mouseoverLocation$ | async" which receives the object @Input() mouseoverLocation: Location | null = null; and actually displays the data in the template using interpolation as follows:
<div fxFlex="{{lineHeight}}px">
<div fxLayout="row" fxLayoutGap="8px">
<div fxFlex="200px;" class="mouse-over-sub-hdg">Location: </div>
<div fxFlex>{{mouseoverLocation?.name}}</div>
</div>
</div>
When run, both in developer and production builds, the displayed value lags badly behind the mouse and does not at all match the stream of data being received in the Observable. In fact if I circle the mouse over the grid so it never stops moving the template does not update at all. Only when I slow the mouse or stop does it jump to the value of the square the mouse is over.
I have tried refactoring this in many different ways including forcing change detection as each new value is received by the Observable cdRef.markForCheck() and cdRef.detectChanges(). I tried creating a @ViewChild of the location-display component and directly updating the mouseoverLocation value. I tried using the ngrxPush pipe. Every time the same result. All components implement OnPush.
I removed all the Zone related code thinking that this may be the root of the problem, but it lagged just the same, though I could see the considerable increase in change detection for every mousemove event.
Perhaps Chrome's DevTools Performance tab may provide a clue but honestly I can't get my head around how to use it.
Any advise, thoughts or suggestions very welcome. Thank you.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|
