Skip to content

Commit 4972dc5

Browse files
arturovtandrewseguin
authored andcommitted
perf(material/button): do not run change detection when the anchor is clicked (#23992)
(cherry picked from commit 41f0244)
1 parent eae436f commit 4972dc5

File tree

7 files changed

+99
-19
lines changed

7 files changed

+99
-19
lines changed

src/material-experimental/mdc-button/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ ng_test_library(
102102
deps = [
103103
":mdc-button",
104104
"//src/cdk/platform",
105+
"//src/cdk/testing/private",
105106
"//src/material-experimental/mdc-core",
106107
"//src/material/button",
107108
"@npm//@angular/platform-browser",

src/material-experimental/mdc-button/button-base.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {Platform} from '@angular/cdk/platform';
10-
import {Directive, ElementRef, NgZone, ViewChild} from '@angular/core';
10+
import {Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild} from '@angular/core';
1111
import {
1212
CanColor,
1313
CanDisable,
@@ -155,23 +155,29 @@ export const MAT_ANCHOR_HOST = {
155155
/**
156156
* Anchor button base.
157157
*/
158-
@Directive({
159-
host: {
160-
'(click)': '_haltDisabledEvents($event)',
161-
},
162-
})
163-
export class MatAnchorBase extends MatButtonBase {
158+
@Directive()
159+
export class MatAnchorBase extends MatButtonBase implements OnInit, OnDestroy {
164160
tabIndex: number;
165161

166162
constructor(elementRef: ElementRef, platform: Platform, ngZone: NgZone, animationMode?: string) {
167163
super(elementRef, platform, ngZone, animationMode);
168164
}
169165

170-
_haltDisabledEvents(event: Event) {
166+
ngOnInit(): void {
167+
this._ngZone.runOutsideAngular(() => {
168+
this._elementRef.nativeElement.addEventListener('click', this._haltDisabledEvents);
169+
});
170+
}
171+
172+
ngOnDestroy(): void {
173+
this._elementRef.nativeElement.removeEventListener('click', this._haltDisabledEvents);
174+
}
175+
176+
_haltDisabledEvents = (event: Event): void => {
171177
// A disabled button shouldn't apply any actions
172178
if (this.disabled) {
173179
event.preventDefault();
174180
event.stopImmediatePropagation();
175181
}
176-
}
182+
};
177183
}

src/material-experimental/mdc-button/button.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
2-
import {Component, DebugElement} from '@angular/core';
2+
import {ApplicationRef, Component, DebugElement} from '@angular/core';
33
import {By} from '@angular/platform-browser';
44
import {MatButtonModule, MatButton, MatFabDefaultOptions, MAT_FAB_DEFAULT_OPTIONS} from './index';
55
import {MatRipple, ThemePalette} from '@angular/material-experimental/mdc-core';
6+
import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private';
67

78
describe('MDC-based MatButton', () => {
89
beforeEach(
@@ -229,6 +230,28 @@ describe('MDC-based MatButton', () => {
229230
.withContext('Expected custom tabindex to be overwritten when disabled.')
230231
.toBe('-1');
231232
});
233+
234+
describe('change detection behavior', () => {
235+
it('should not run change detection for disabled anchor but should prevent the default behavior and stop event propagation', () => {
236+
const appRef = TestBed.inject(ApplicationRef);
237+
const fixture = TestBed.createComponent(TestApp);
238+
fixture.componentInstance.isDisabled = true;
239+
fixture.detectChanges();
240+
const anchorElement = fixture.debugElement.query(By.css('a'))!.nativeElement;
241+
242+
spyOn(appRef, 'tick');
243+
244+
const event = createMouseEvent('click');
245+
spyOn(event, 'preventDefault').and.callThrough();
246+
spyOn(event, 'stopImmediatePropagation').and.callThrough();
247+
248+
dispatchEvent(anchorElement, event);
249+
250+
expect(appRef.tick).not.toHaveBeenCalled();
251+
expect(event.preventDefault).toHaveBeenCalled();
252+
expect(event.stopImmediatePropagation).toHaveBeenCalled();
253+
});
254+
});
232255
});
233256

234257
// Ripple tests.

src/material/button/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ ng_test_library(
5252
),
5353
deps = [
5454
":button",
55+
"//src/cdk/testing/private",
5556
"//src/material/core",
5657
"@npm//@angular/platform-browser",
5758
],

src/material/button/button.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
2-
import {Component, DebugElement} from '@angular/core';
2+
import {ApplicationRef, Component, DebugElement} from '@angular/core';
33
import {By} from '@angular/platform-browser';
44
import {MatButtonModule, MatButton} from './index';
55
import {MatRipple, ThemePalette} from '@angular/material/core';
6+
import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private';
67

78
describe('MatButton', () => {
89
beforeEach(
@@ -243,6 +244,28 @@ describe('MatButton', () => {
243244
.withContext('Expected custom tabindex to be overwritten when disabled.')
244245
.toBe('-1');
245246
});
247+
248+
describe('change detection behavior', () => {
249+
it('should not run change detection for disabled anchor but should prevent the default behavior and stop event propagation', () => {
250+
const appRef = TestBed.inject(ApplicationRef);
251+
const fixture = TestBed.createComponent(TestApp);
252+
fixture.componentInstance.isDisabled = true;
253+
fixture.detectChanges();
254+
const anchorElement = fixture.debugElement.query(By.css('a'))!.nativeElement;
255+
256+
spyOn(appRef, 'tick');
257+
258+
const event = createMouseEvent('click');
259+
spyOn(event, 'preventDefault').and.callThrough();
260+
spyOn(event, 'stopImmediatePropagation').and.callThrough();
261+
262+
dispatchEvent(anchorElement, event);
263+
264+
expect(appRef.tick).not.toHaveBeenCalled();
265+
expect(event.preventDefault).toHaveBeenCalled();
266+
expect(event.stopImmediatePropagation).toHaveBeenCalled();
267+
});
268+
});
246269
});
247270

248271
// Ripple tests.

src/material/button/button.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Inject,
1919
Input,
2020
AfterViewInit,
21+
NgZone,
2122
} from '@angular/core';
2223
import {
2324
CanColor,
@@ -164,7 +165,6 @@ export class MatButton
164165
'[attr.tabindex]': 'disabled ? -1 : (tabIndex || 0)',
165166
'[attr.disabled]': 'disabled || null',
166167
'[attr.aria-disabled]': 'disabled.toString()',
167-
'(click)': '_haltDisabledEvents($event)',
168168
'[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"',
169169
'[class.mat-button-disabled]': 'disabled',
170170
'class': 'mat-focus-indicator',
@@ -175,23 +175,43 @@ export class MatButton
175175
encapsulation: ViewEncapsulation.None,
176176
changeDetection: ChangeDetectionStrategy.OnPush,
177177
})
178-
export class MatAnchor extends MatButton {
178+
export class MatAnchor extends MatButton implements AfterViewInit, OnDestroy {
179179
/** Tabindex of the button. */
180180
@Input() tabIndex: number;
181181

182182
constructor(
183183
focusMonitor: FocusMonitor,
184184
elementRef: ElementRef,
185185
@Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode: string,
186+
/** @breaking-change 14.0.0 _ngZone will be required. */
187+
@Optional() private _ngZone?: NgZone,
186188
) {
187189
super(elementRef, focusMonitor, animationMode);
188190
}
189191

190-
_haltDisabledEvents(event: Event) {
192+
override ngAfterViewInit(): void {
193+
super.ngAfterViewInit();
194+
195+
/** @breaking-change 14.0.0 _ngZone will be required. */
196+
if (this._ngZone) {
197+
this._ngZone.runOutsideAngular(() => {
198+
this._elementRef.nativeElement.addEventListener('click', this._haltDisabledEvents);
199+
});
200+
} else {
201+
this._elementRef.nativeElement.addEventListener('click', this._haltDisabledEvents);
202+
}
203+
}
204+
205+
override ngOnDestroy(): void {
206+
super.ngOnDestroy();
207+
this._elementRef.nativeElement.removeEventListener('click', this._haltDisabledEvents);
208+
}
209+
210+
_haltDisabledEvents = (event: Event): void => {
191211
// A disabled button shouldn't apply any actions
192212
if (this.disabled) {
193213
event.preventDefault();
194214
event.stopImmediatePropagation();
195215
}
196-
}
216+
};
197217
}

tools/public_api_guard/material/button.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,24 @@ import { FocusOrigin } from '@angular/cdk/a11y';
1717
import * as i0 from '@angular/core';
1818
import * as i2 from '@angular/material/core';
1919
import { MatRipple } from '@angular/material/core';
20+
import { NgZone } from '@angular/core';
2021
import { OnDestroy } from '@angular/core';
2122

2223
// @public
23-
export class MatAnchor extends MatButton {
24-
constructor(focusMonitor: FocusMonitor, elementRef: ElementRef, animationMode: string);
24+
export class MatAnchor extends MatButton implements AfterViewInit, OnDestroy {
25+
constructor(focusMonitor: FocusMonitor, elementRef: ElementRef, animationMode: string,
26+
_ngZone?: NgZone | undefined);
2527
// (undocumented)
26-
_haltDisabledEvents(event: Event): void;
28+
_haltDisabledEvents: (event: Event) => void;
29+
// (undocumented)
30+
ngAfterViewInit(): void;
31+
// (undocumented)
32+
ngOnDestroy(): void;
2733
tabIndex: number;
2834
// (undocumented)
2935
static ɵcmp: i0.ɵɵComponentDeclaration<MatAnchor, "a[mat-button], a[mat-raised-button], a[mat-icon-button], a[mat-fab], a[mat-mini-fab], a[mat-stroked-button], a[mat-flat-button]", ["matButton", "matAnchor"], { "disabled": "disabled"; "disableRipple": "disableRipple"; "color": "color"; "tabIndex": "tabIndex"; }, {}, never, ["*"]>;
3036
// (undocumented)
31-
static ɵfac: i0.ɵɵFactoryDeclaration<MatAnchor, [null, null, { optional: true; }]>;
37+
static ɵfac: i0.ɵɵFactoryDeclaration<MatAnchor, [null, null, { optional: true; }, { optional: true; }]>;
3238
}
3339

3440
// @public

0 commit comments

Comments
 (0)