Skip to content

perf(table) Coalesces style updates after style measurements to reduc… #19750

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jul 27, 2020
Merged
80 changes: 80 additions & 0 deletions src/cdk/table/coalesced-style-scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Injectable, NgZone, OnDestroy} from '@angular/core';
import {Subject} from 'rxjs';
import {take, takeUntil} from 'rxjs/operators';

/**
* @docs-private
*/
export class _Schedule {
tasks: (() => unknown)[] = [];
endTasks: (() => unknown)[] = [];
}

/**
* Allows grouping up CSSDom mutations after the current execution context.
* This can significantly improve performance when separate consecutive functions are
* reading from the CSSDom and then mutating it.
*
* @docs-private
*/
@Injectable()
export class _CoalescedStyleScheduler implements OnDestroy {
private _currentSchedule: _Schedule|null = null;
private readonly _destroyed = new Subject<void>();

constructor(private readonly _ngZone: NgZone) {}

/**
* Schedules the specified task to run at the end of the current VM turn.
*/
schedule(task: () => unknown): void {
this._createScheduleIfNeeded();

this._currentSchedule!.tasks.push(task);
}

/**
* Schedules the specified task to run after other scheduled tasks at the end of the current
* VM turn.
*/
scheduleEnd(task: () => unknown): void {
this._createScheduleIfNeeded();

this._currentSchedule!.endTasks.push(task);
}

/** Prevent any further tasks from running. */
ngOnDestroy() {
this._destroyed.next();
this._destroyed.complete();
}

private _createScheduleIfNeeded() {
if (this._currentSchedule) { return; }

this._currentSchedule = new _Schedule();

this._ngZone.onStable.pipe(
take(1),
takeUntil(this._destroyed),
).subscribe(() => {
const schedule = this._currentSchedule!;
this._currentSchedule = null;

for (const task of schedule.tasks) {
task();
}
for (const task of schedule.endTasks) {
task();
}
});
}
}
1 change: 1 addition & 0 deletions src/cdk/table/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

export * from './table';
export * from './cell';
export * from './coalesced-style-scheduler';
export * from './row';
export * from './table-module';
export * from './sticky-styler';
Expand Down
104 changes: 65 additions & 39 deletions src/cdk/table/sticky-styler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* @docs-private
*/
import {Direction} from '@angular/cdk/bidi';
import {_CoalescedStyleScheduler} from './coalesced-style-scheduler';

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

Expand All @@ -37,6 +38,7 @@ export class StickyStyler {
constructor(private _isNativeHtmlTable: boolean,
private _stickCellCss: string,
public direction: Direction,
private _coalescedStyleScheduler: _CoalescedStyleScheduler,
private _isBrowser = true) { }

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

this._removeStickyStyle(row, stickyDirections);

elementsToClear.push(row);
for (let i = 0; i < row.children.length; i++) {
const cell = row.children[i] as HTMLElement;
this._removeStickyStyle(cell, stickyDirections);
elementsToClear.push(row.children[i] as HTMLElement);
}
}

// Coalesce with sticky row/column updates (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
for (const element of elementsToClear) {
this._removeStickyStyle(element, stickyDirections);
}
});
}

/**
Expand All @@ -73,9 +81,8 @@ export class StickyStyler {
*/
updateStickyColumns(
rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[]) {
const hasStickyColumns =
stickyStartStates.some(state => state) || stickyEndStates.some(state => state);
if (!rows.length || !hasStickyColumns || !this._isBrowser) {
if (!rows.length || !this._isBrowser || !(stickyStartStates.some(state => state) ||
stickyEndStates.some(state => state))) {
return;
}

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

const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);
const isRtl = this.direction === 'rtl';

for (const row of rows) {
for (let i = 0; i < numCells; i++) {
const cell = row.children[i] as HTMLElement;
if (stickyStartStates[i]) {
this._addStickyStyle(cell, isRtl ? 'right' : 'left', startPositions[i]);
}

if (stickyEndStates[i]) {
this._addStickyStyle(cell, isRtl ? 'left' : 'right', endPositions[i]);
// Coalesce with sticky row updates (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
const isRtl = this.direction === 'rtl';
const start = isRtl ? 'right' : 'left';
const end = isRtl ? 'left' : 'right';

for (const row of rows) {
for (let i = 0; i < numCells; i++) {
const cell = row.children[i] as HTMLElement;
if (stickyStartStates[i]) {
this._addStickyStyle(cell, start, startPositions[i]);
}

if (stickyEndStates[i]) {
this._addStickyStyle(cell, end, endPositions[i]);
}
}
}
}
});
}

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

let stickyHeight = 0;
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
const stickyHeights: number[] = [];
const elementsToStick: HTMLElement[][] = [];
for (let rowIndex = 0, stickyHeight = 0; rowIndex < rows.length; rowIndex++) {
stickyHeights[rowIndex] = stickyHeight;

if (!states[rowIndex]) {
continue;
}

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

if (rowIndex === rows.length - 1) {
// prevent unnecessary reflow from getBoundingClientRect()
return;
if (rowIndex !== rows.length - 1) {
stickyHeight += row.getBoundingClientRect().height;
}
stickyHeight += row.getBoundingClientRect().height;
}

// Coalesce with other sticky row updates (top/bottom), sticky columns updates
// (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
if (!states[rowIndex]) {
continue;
}

const height = stickyHeights[rowIndex];
for (const element of elementsToStick[rowIndex]) {
this._addStickyStyle(element, position, height);
}
}
});
}

/**
Expand All @@ -162,11 +184,15 @@ export class StickyStyler {
}

const tfoot = tableElement.querySelector('tfoot')!;
if (stickyStates.some(state => !state)) {
this._removeStickyStyle(tfoot, ['bottom']);
} else {
this._addStickyStyle(tfoot, 'bottom', 0);
}

// Coalesce with other sticky updates (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
if (stickyStates.some(state => !state)) {
this._removeStickyStyle(tfoot, ['bottom']);
} else {
this._addStickyStyle(tfoot, 'bottom', 0);
}
});
}

/**
Expand Down
Loading