Skip to content

Commit 71a2a17

Browse files
kseamonjelbourn
authored andcommitted
perf(table) Coalesces style updates after style measurements to reduc… (#19750)
* perf(table) Coalesces style updates after style measurements to reduce layout thrashing Exposes the CoalescedStyleScheduler for use by other related components in a table such as column resize. * Add license * Fix column resize tests * Fixed mdc-table tests * jsdoc * more comments * lint * lint * Added _ * lint * -override * update api * prevent resource leaks * api * readonly * remove changes that are part of #19739 * Change to onStable to work around downstream test failures * api update (cherry picked from commit ef8fc4f)
1 parent 34abb58 commit 71a2a17

File tree

11 files changed

+250
-82
lines changed

11 files changed

+250
-82
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Injectable, NgZone, OnDestroy} from '@angular/core';
10+
import {Subject} from 'rxjs';
11+
import {take, takeUntil} from 'rxjs/operators';
12+
13+
/**
14+
* @docs-private
15+
*/
16+
export class _Schedule {
17+
tasks: (() => unknown)[] = [];
18+
endTasks: (() => unknown)[] = [];
19+
}
20+
21+
/**
22+
* Allows grouping up CSSDom mutations after the current execution context.
23+
* This can significantly improve performance when separate consecutive functions are
24+
* reading from the CSSDom and then mutating it.
25+
*
26+
* @docs-private
27+
*/
28+
@Injectable()
29+
export class _CoalescedStyleScheduler implements OnDestroy {
30+
private _currentSchedule: _Schedule|null = null;
31+
private readonly _destroyed = new Subject<void>();
32+
33+
constructor(private readonly _ngZone: NgZone) {}
34+
35+
/**
36+
* Schedules the specified task to run at the end of the current VM turn.
37+
*/
38+
schedule(task: () => unknown): void {
39+
this._createScheduleIfNeeded();
40+
41+
this._currentSchedule!.tasks.push(task);
42+
}
43+
44+
/**
45+
* Schedules the specified task to run after other scheduled tasks at the end of the current
46+
* VM turn.
47+
*/
48+
scheduleEnd(task: () => unknown): void {
49+
this._createScheduleIfNeeded();
50+
51+
this._currentSchedule!.endTasks.push(task);
52+
}
53+
54+
/** Prevent any further tasks from running. */
55+
ngOnDestroy() {
56+
this._destroyed.next();
57+
this._destroyed.complete();
58+
}
59+
60+
private _createScheduleIfNeeded() {
61+
if (this._currentSchedule) { return; }
62+
63+
this._currentSchedule = new _Schedule();
64+
65+
this._ngZone.onStable.pipe(
66+
take(1),
67+
takeUntil(this._destroyed),
68+
).subscribe(() => {
69+
const schedule = this._currentSchedule!;
70+
this._currentSchedule = null;
71+
72+
for (const task of schedule.tasks) {
73+
task();
74+
}
75+
for (const task of schedule.endTasks) {
76+
task();
77+
}
78+
});
79+
}
80+
}

src/cdk/table/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
export * from './table';
1010
export * from './cell';
11+
export * from './coalesced-style-scheduler';
1112
export * from './row';
1213
export * from './table-module';
1314
export * from './sticky-styler';

src/cdk/table/sticky-styler.ts

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* @docs-private
1212
*/
1313
import {Direction} from '@angular/cdk/bidi';
14+
import {_CoalescedStyleScheduler} from './coalesced-style-scheduler';
1415

1516
export type StickyDirection = 'top' | 'bottom' | 'left' | 'right';
1617

@@ -37,6 +38,7 @@ export class StickyStyler {
3738
constructor(private _isNativeHtmlTable: boolean,
3839
private _stickCellCss: string,
3940
public direction: Direction,
41+
private _coalescedStyleScheduler: _CoalescedStyleScheduler,
4042
private _isBrowser = true) { }
4143

4244
/**
@@ -46,20 +48,26 @@ export class StickyStyler {
4648
* @param stickyDirections The directions that should no longer be set as sticky on the rows.
4749
*/
4850
clearStickyPositioning(rows: HTMLElement[], stickyDirections: StickyDirection[]) {
51+
const elementsToClear: HTMLElement[] = [];
4952
for (const row of rows) {
5053
// If the row isn't an element (e.g. if it's an `ng-container`),
5154
// it won't have inline styles or `children` so we skip it.
5255
if (row.nodeType !== row.ELEMENT_NODE) {
5356
continue;
5457
}
5558

56-
this._removeStickyStyle(row, stickyDirections);
57-
59+
elementsToClear.push(row);
5860
for (let i = 0; i < row.children.length; i++) {
59-
const cell = row.children[i] as HTMLElement;
60-
this._removeStickyStyle(cell, stickyDirections);
61+
elementsToClear.push(row.children[i] as HTMLElement);
6162
}
6263
}
64+
65+
// Coalesce with sticky row/column updates (and potentially other changes like column resize).
66+
this._coalescedStyleScheduler.schedule(() => {
67+
for (const element of elementsToClear) {
68+
this._removeStickyStyle(element, stickyDirections);
69+
}
70+
});
6371
}
6472

6573
/**
@@ -73,9 +81,8 @@ export class StickyStyler {
7381
*/
7482
updateStickyColumns(
7583
rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[]) {
76-
const hasStickyColumns =
77-
stickyStartStates.some(state => state) || stickyEndStates.some(state => state);
78-
if (!rows.length || !hasStickyColumns || !this._isBrowser) {
84+
if (!rows.length || !this._isBrowser || !(stickyStartStates.some(state => state) ||
85+
stickyEndStates.some(state => state))) {
7986
return;
8087
}
8188

@@ -85,20 +92,26 @@ export class StickyStyler {
8592

8693
const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
8794
const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);
88-
const isRtl = this.direction === 'rtl';
89-
90-
for (const row of rows) {
91-
for (let i = 0; i < numCells; i++) {
92-
const cell = row.children[i] as HTMLElement;
93-
if (stickyStartStates[i]) {
94-
this._addStickyStyle(cell, isRtl ? 'right' : 'left', startPositions[i]);
95-
}
9695

97-
if (stickyEndStates[i]) {
98-
this._addStickyStyle(cell, isRtl ? 'left' : 'right', endPositions[i]);
96+
// Coalesce with sticky row updates (and potentially other changes like column resize).
97+
this._coalescedStyleScheduler.schedule(() => {
98+
const isRtl = this.direction === 'rtl';
99+
const start = isRtl ? 'right' : 'left';
100+
const end = isRtl ? 'left' : 'right';
101+
102+
for (const row of rows) {
103+
for (let i = 0; i < numCells; i++) {
104+
const cell = row.children[i] as HTMLElement;
105+
if (stickyStartStates[i]) {
106+
this._addStickyStyle(cell, start, startPositions[i]);
107+
}
108+
109+
if (stickyEndStates[i]) {
110+
this._addStickyStyle(cell, end, endPositions[i]);
111+
}
99112
}
100113
}
101-
}
114+
});
102115
}
103116

104117
/**
@@ -124,30 +137,39 @@ export class StickyStyler {
124137
const rows = position === 'bottom' ? rowsToStick.slice().reverse() : rowsToStick;
125138
const states = position === 'bottom' ? stickyStates.slice().reverse() : stickyStates;
126139

127-
let stickyHeight = 0;
128-
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
140+
// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
141+
const stickyHeights: number[] = [];
142+
const elementsToStick: HTMLElement[][] = [];
143+
for (let rowIndex = 0, stickyHeight = 0; rowIndex < rows.length; rowIndex++) {
144+
stickyHeights[rowIndex] = stickyHeight;
145+
129146
if (!states[rowIndex]) {
130147
continue;
131148
}
132149

133150
const row = rows[rowIndex];
134-
if (this._isNativeHtmlTable) {
135-
for (let j = 0; j < row.children.length; j++) {
136-
const cell = row.children[j] as HTMLElement;
137-
this._addStickyStyle(cell, position, stickyHeight);
138-
}
139-
} else {
140-
// Flex does not respect the stick positioning on the cells, needs to be applied to the row.
141-
// If this is applied on a native table, Safari causes the header to fly in wrong direction.
142-
this._addStickyStyle(row, position, stickyHeight);
143-
}
151+
elementsToStick[rowIndex] = this._isNativeHtmlTable ?
152+
Array.from(row.children) as HTMLElement[] : [row];
144153

145-
if (rowIndex === rows.length - 1) {
146-
// prevent unnecessary reflow from getBoundingClientRect()
147-
return;
154+
if (rowIndex !== rows.length - 1) {
155+
stickyHeight += row.getBoundingClientRect().height;
148156
}
149-
stickyHeight += row.getBoundingClientRect().height;
150157
}
158+
159+
// Coalesce with other sticky row updates (top/bottom), sticky columns updates
160+
// (and potentially other changes like column resize).
161+
this._coalescedStyleScheduler.schedule(() => {
162+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
163+
if (!states[rowIndex]) {
164+
continue;
165+
}
166+
167+
const height = stickyHeights[rowIndex];
168+
for (const element of elementsToStick[rowIndex]) {
169+
this._addStickyStyle(element, position, height);
170+
}
171+
}
172+
});
151173
}
152174

153175
/**
@@ -162,11 +184,15 @@ export class StickyStyler {
162184
}
163185

164186
const tfoot = tableElement.querySelector('tfoot')!;
165-
if (stickyStates.some(state => !state)) {
166-
this._removeStickyStyle(tfoot, ['bottom']);
167-
} else {
168-
this._addStickyStyle(tfoot, 'bottom', 0);
169-
}
187+
188+
// Coalesce with other sticky updates (and potentially other changes like column resize).
189+
this._coalescedStyleScheduler.schedule(() => {
190+
if (stickyStates.some(state => !state)) {
191+
this._removeStickyStyle(tfoot, ['bottom']);
192+
} else {
193+
this._addStickyStyle(tfoot, 'bottom', 0);
194+
}
195+
});
170196
}
171197

172198
/**

0 commit comments

Comments
 (0)