Skip to content

Commit ad547fb

Browse files
authored
perf(module:typography): do not run change detection on input and keydown events (#7185)
1 parent 3580fb0 commit ad547fb

File tree

2 files changed

+169
-52
lines changed

2 files changed

+169
-52
lines changed

components/typography/text-edit.component.ts

Lines changed: 103 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
44
*/
55

6+
import { ENTER, ESCAPE } from '@angular/cdk/keycodes';
67
import {
78
ChangeDetectionStrategy,
89
ChangeDetectorRef,
@@ -11,15 +12,15 @@ import {
1112
EventEmitter,
1213
Input,
1314
NgZone,
14-
OnDestroy,
1515
OnInit,
1616
Output,
1717
ViewChild,
1818
ViewEncapsulation
1919
} from '@angular/core';
20-
import { Subject } from 'rxjs';
21-
import { take, takeUntil } from 'rxjs/operators';
20+
import { BehaviorSubject, fromEvent, Observable } from 'rxjs';
21+
import { filter, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators';
2222

23+
import { NzDestroyService } from 'ng-zorro-antd/core/services';
2324
import { NzTSType } from 'ng-zorro-antd/core/types';
2425
import { NzI18nService, NzTextI18nInterface } from 'ng-zorro-antd/i18n';
2526
import { NzAutosizeDirective } from 'ng-zorro-antd/input';
@@ -28,70 +29,125 @@ import { NzAutosizeDirective } from 'ng-zorro-antd/input';
2829
selector: 'nz-text-edit',
2930
exportAs: 'nzTextEdit',
3031
template: `
31-
<button
32-
*ngIf="!editing"
33-
nz-tooltip
34-
nz-trans-button
35-
class="ant-typography-edit"
36-
[nzTooltipTitle]="tooltip === null ? null : tooltip || locale?.edit"
37-
(click)="onClick()"
38-
>
39-
<ng-container *nzStringTemplateOutlet="icon; let icon">
40-
<i nz-icon [nzType]="icon"></i>
41-
</ng-container>
42-
</button>
43-
<ng-container *ngIf="editing">
44-
<textarea
45-
#textarea
46-
nz-input
47-
nzAutosize
48-
(input)="onInput($event)"
49-
(blur)="confirm()"
50-
(keydown.esc)="onCancel()"
51-
(keydown.enter)="onEnter($event)"
52-
></textarea>
32+
<ng-template [ngIf]="editing" [ngIfElse]="notEditing">
33+
<textarea #textarea nz-input nzAutosize (blur)="confirm()"></textarea>
5334
<button nz-trans-button class="ant-typography-edit-content-confirm" (click)="confirm()">
5435
<i nz-icon nzType="enter"></i>
5536
</button>
56-
</ng-container>
37+
</ng-template>
38+
39+
<ng-template #notEditing>
40+
<button
41+
nz-tooltip
42+
nz-trans-button
43+
class="ant-typography-edit"
44+
[nzTooltipTitle]="tooltip === null ? null : tooltip || locale?.edit"
45+
(click)="onClick()"
46+
>
47+
<ng-container *nzStringTemplateOutlet="icon; let icon">
48+
<i nz-icon [nzType]="icon"></i>
49+
</ng-container>
50+
</button>
51+
</ng-template>
5752
`,
5853
changeDetection: ChangeDetectionStrategy.OnPush,
5954
encapsulation: ViewEncapsulation.None,
60-
preserveWhitespaces: false
55+
preserveWhitespaces: false,
56+
providers: [NzDestroyService]
6157
})
62-
export class NzTextEditComponent implements OnInit, OnDestroy {
58+
export class NzTextEditComponent implements OnInit {
6359
editing = false;
6460
locale!: NzTextI18nInterface;
65-
private destroy$ = new Subject();
6661

6762
@Input() text?: string;
6863
@Input() icon: NzTSType = 'edit';
6964
@Input() tooltip?: null | NzTSType;
7065
@Output() readonly startEditing = new EventEmitter<void>();
7166
@Output() readonly endEditing = new EventEmitter<string>(true);
72-
@ViewChild('textarea', { static: false }) textarea!: ElementRef<HTMLTextAreaElement>;
67+
@ViewChild('textarea', { static: false })
68+
set textarea(textarea: ElementRef<HTMLTextAreaElement> | undefined) {
69+
if (textarea) {
70+
this.textarea$.next(textarea);
71+
}
72+
}
7373
@ViewChild(NzAutosizeDirective, { static: false }) autosizeDirective!: NzAutosizeDirective;
7474

7575
beforeText?: string;
7676
currentText?: string;
7777
nativeElement = this.host.nativeElement;
78+
79+
// We could've saved the textarea within some private property (e.g. `_textarea`) and have a getter,
80+
// but having subject makes the code more reactive and cancellable (e.g. event listeners will be
81+
// automatically removed and re-added through the `switchMap` below).
82+
private textarea$ = new BehaviorSubject<ElementRef<HTMLTextAreaElement> | null>(null);
83+
7884
constructor(
79-
private zone: NgZone,
80-
private host: ElementRef,
85+
private ngZone: NgZone,
86+
private host: ElementRef<HTMLElement>,
8187
private cdr: ChangeDetectorRef,
82-
private i18n: NzI18nService
88+
private i18n: NzI18nService,
89+
private destroy$: NzDestroyService
8390
) {}
8491

8592
ngOnInit(): void {
8693
this.i18n.localeChange.pipe(takeUntil(this.destroy$)).subscribe(() => {
8794
this.locale = this.i18n.getLocaleData('Text');
8895
this.cdr.markForCheck();
8996
});
90-
}
9197

92-
ngOnDestroy(): void {
93-
this.destroy$.next();
94-
this.destroy$.complete();
98+
const textarea$: Observable<ElementRef<HTMLTextAreaElement>> = this.textarea$.pipe(
99+
filter((textarea): textarea is ElementRef<HTMLTextAreaElement> => textarea !== null)
100+
);
101+
102+
textarea$
103+
.pipe(
104+
switchMap(
105+
textarea =>
106+
// Caretaker note: we explicitly should call `subscribe()` within the root zone.
107+
// `runOutsideAngular(() => fromEvent(...))` will just create an observable within the root zone,
108+
// but `addEventListener` is called when the `fromEvent` is subscribed.
109+
new Observable<KeyboardEvent>(subscriber =>
110+
this.ngZone.runOutsideAngular(() =>
111+
fromEvent<KeyboardEvent>(textarea.nativeElement, 'keydown').subscribe(subscriber)
112+
)
113+
)
114+
),
115+
takeUntil(this.destroy$)
116+
)
117+
.subscribe(event => {
118+
// Caretaker note: adding modifier at the end (for instance `(keydown.esc)`) will tell Angular to add
119+
// an event listener through the `KeyEventsPlugin`, which always runs `ngZone.runGuarded()` internally.
120+
// We're interested only in escape and enter keyboard buttons, otherwise Angular will run change detection
121+
// on any `keydown` event.
122+
if (event.keyCode !== ESCAPE && event.keyCode !== ENTER) {
123+
return;
124+
}
125+
126+
this.ngZone.run(() => {
127+
if (event.keyCode === ESCAPE) {
128+
this.onCancel();
129+
} else {
130+
this.onEnter(event);
131+
}
132+
this.cdr.markForCheck();
133+
});
134+
});
135+
136+
textarea$
137+
.pipe(
138+
switchMap(
139+
textarea =>
140+
new Observable<KeyboardEvent>(subscriber =>
141+
this.ngZone.runOutsideAngular(() =>
142+
fromEvent<KeyboardEvent>(textarea.nativeElement, 'input').subscribe(subscriber)
143+
)
144+
)
145+
),
146+
takeUntil(this.destroy$)
147+
)
148+
.subscribe(event => {
149+
this.currentText = (event.target as HTMLTextAreaElement).value;
150+
});
95151
}
96152

97153
onClick(): void {
@@ -107,11 +163,6 @@ export class NzTextEditComponent implements OnInit, OnDestroy {
107163
this.endEditing.emit(this.currentText);
108164
}
109165

110-
onInput(event: Event): void {
111-
const target = event.target as HTMLTextAreaElement;
112-
this.currentText = target.value;
113-
}
114-
115166
onEnter(event: Event): void {
116167
event.stopPropagation();
117168
event.preventDefault();
@@ -124,13 +175,15 @@ export class NzTextEditComponent implements OnInit, OnDestroy {
124175
}
125176

126177
focusAndSetValue(): void {
127-
this.zone.onStable.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
128-
if (this.textarea?.nativeElement) {
129-
this.textarea.nativeElement.focus();
130-
this.textarea.nativeElement.value = this.currentText || '';
131-
this.autosizeDirective.resizeToFitContent();
132-
this.cdr.markForCheck();
133-
}
134-
});
178+
this.ngZone.onStable
179+
.pipe(take(1), withLatestFrom(this.textarea$), takeUntil(this.destroy$))
180+
.subscribe(([, textarea]) => {
181+
if (textarea) {
182+
textarea.nativeElement.focus();
183+
textarea.nativeElement.value = this.currentText || '';
184+
this.autosizeDirective.resizeToFitContent();
185+
this.cdr.markForCheck();
186+
}
187+
});
135188
}
136189
}

components/typography/typography.spec.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import { ENTER } from '@angular/cdk/keycodes';
1+
import { CAPS_LOCK, ENTER, ESCAPE, TAB } from '@angular/cdk/keycodes';
22
import { OverlayContainer } from '@angular/cdk/overlay';
33
import { CommonModule } from '@angular/common';
4-
import { Component, NgZone, ViewChild } from '@angular/core';
4+
import { ApplicationRef, Component, NgZone, ViewChild } from '@angular/core';
55
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick } from '@angular/core/testing';
6+
import { By } from '@angular/platform-browser';
67
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
78

89
import {
910
createKeyboardEvent,
1011
dispatchFakeEvent,
12+
dispatchKeyboardEvent,
1113
dispatchMouseEvent,
1214
MockNgZone,
1315
typeInElement
1416
} from 'ng-zorro-antd/core/testing';
1517
import { NzSafeAny } from 'ng-zorro-antd/core/types';
1618
import { NzIconTestModule } from 'ng-zorro-antd/icon/testing';
1719

20+
import { NzTextEditComponent } from '.';
1821
import { NzTypographyComponent } from './typography.component';
1922
import { NzTypographyModule } from './typography.module';
2023

@@ -480,6 +483,67 @@ describe('typography', () => {
480483
});
481484
});
482485

486+
// Caretaker note: this is moved to a separate `describe` block because the first `describe` block
487+
// mocks the `NgZone` with `MockNgZone`.
488+
describe('change detection behavior', () => {
489+
let componentElement: HTMLElement;
490+
let fixture: ComponentFixture<NzTestTypographyEditComponent>;
491+
492+
beforeEach(() => {
493+
TestBed.configureTestingModule({
494+
imports: [CommonModule, NzTypographyModule, NzIconTestModule, NoopAnimationsModule],
495+
declarations: [NzTestTypographyEditComponent]
496+
}).compileComponents();
497+
});
498+
499+
beforeEach(() => {
500+
fixture = TestBed.createComponent(NzTestTypographyEditComponent);
501+
componentElement = fixture.debugElement.nativeElement;
502+
fixture.detectChanges();
503+
});
504+
505+
it('should not run change detection on `input` event', () => {
506+
componentElement.querySelector<HTMLButtonElement>('.ant-typography-edit')!.click();
507+
fixture.detectChanges();
508+
509+
const appRef = TestBed.inject(ApplicationRef);
510+
spyOn(appRef, 'tick');
511+
512+
const nzTextEdit = fixture.debugElement.query(By.directive(NzTextEditComponent));
513+
const textarea: HTMLTextAreaElement = nzTextEdit.nativeElement.querySelector('textarea');
514+
515+
textarea.value = 'some-value';
516+
dispatchFakeEvent(textarea, 'input');
517+
518+
expect(appRef.tick).not.toHaveBeenCalled();
519+
expect(nzTextEdit.componentInstance.currentText).toEqual('some-value');
520+
});
521+
522+
it('should not run change detection on non-handled keydown events', done => {
523+
componentElement.querySelector<HTMLButtonElement>('.ant-typography-edit')!.click();
524+
fixture.detectChanges();
525+
526+
const ngZone = TestBed.inject(NgZone);
527+
const appRef = TestBed.inject(ApplicationRef);
528+
const spy = spyOn(appRef, 'tick');
529+
530+
const nzTextEdit = fixture.debugElement.query(By.directive(NzTextEditComponent));
531+
const textarea: HTMLTextAreaElement = nzTextEdit.nativeElement.querySelector('textarea');
532+
533+
dispatchKeyboardEvent(textarea, 'keydown', TAB);
534+
dispatchKeyboardEvent(textarea, 'keydown', CAPS_LOCK);
535+
536+
expect(spy).not.toHaveBeenCalled();
537+
538+
dispatchKeyboardEvent(textarea, 'keydown', ESCAPE);
539+
540+
ngZone.onMicrotaskEmpty.subscribe(() => {
541+
expect(spy).toHaveBeenCalledTimes(1);
542+
done();
543+
});
544+
});
545+
});
546+
483547
@Component({
484548
template: `
485549
<h1 nz-typography>h1. Ant Design</h1>

0 commit comments

Comments
 (0)