Skip to content

Commit

Permalink
perf(cdk/table): Use afterNextRender for sticky styling. Fixes a perf…
Browse files Browse the repository at this point in the history
…ormance regression dating back to #28393 and removes need for coalesced sticky styler.
  • Loading branch information
kseamon committed Dec 27, 2024
1 parent a6a70f6 commit 5957299
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 132 deletions.
283 changes: 161 additions & 122 deletions src/cdk/table/sticky-styler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
* Directions that can be used when setting sticky positioning.
* @docs-private
*/
import {afterNextRender, Injector} from '@angular/core';
import {Direction} from '@angular/cdk/bidi';
import {_CoalescedStyleScheduler} from './coalesced-style-scheduler';
import {StickyPositioningListener} from './sticky-position-listener';

export type StickyDirection = 'top' | 'bottom' | 'left' | 'right';
Expand Down Expand Up @@ -41,6 +41,7 @@ export class StickyStyler {
private _stickyColumnsReplayTimeout: number | null = null;
private _cachedCellWidths: number[] = [];
private readonly _borderCellCss: Readonly<{[d in StickyDirection]: string}>;
private _destroyed = false;

/**
* @param _isNativeHtmlTable Whether the sticky logic should be based on a table
Expand All @@ -60,10 +61,10 @@ export class StickyStyler {
private _isNativeHtmlTable: boolean,
private _stickCellCss: string,
public direction: Direction,
private _coalescedStyleScheduler: _CoalescedStyleScheduler,
private _isBrowser = true,
private readonly _needsPositionStickyOnElement = true,
private readonly _positionListener?: StickyPositioningListener,
private readonly _tableInjector?: Injector,
) {
this._borderCellCss = {
'top': `${_stickCellCss}-border-elem-top`,
Expand Down Expand Up @@ -92,18 +93,20 @@ export class StickyStyler {
continue;
}

elementsToClear.push(row);
for (let i = 0; i < row.children.length; i++) {
elementsToClear.push(row.children[i] as HTMLElement);
}
elementsToClear.push(row, ...(Array.from(row.children) 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);
}
});
afterNextRender(
{
write: () => {
for (const element of elementsToClear) {
this._removeStickyStyle(element, stickyDirections);
}
},
},
{injector: this._tableInjector},
);
}

/**
Expand Down Expand Up @@ -147,54 +150,67 @@ export class StickyStyler {
}

// Coalesce with sticky row updates (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
const firstRow = rows[0];
const numCells = firstRow.children.length;
const cellWidths: number[] = this._getCellWidths(firstRow, recalculateCellWidths);

const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);

const lastStickyStart = stickyStartStates.lastIndexOf(true);
const firstStickyEnd = stickyEndStates.indexOf(true);

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], i === lastStickyStart);
const firstRow = rows[0];
const numCells = firstRow.children.length;

const isRtl = this.direction === 'rtl';
const start = isRtl ? 'right' : 'left';
const end = isRtl ? 'left' : 'right';

const lastStickyStart = stickyStartStates.lastIndexOf(true);
const firstStickyEnd = stickyEndStates.indexOf(true);

let cellWidths: number[];
let startPositions: number[];
let endPositions: number[];

afterNextRender(
{
earlyRead: () => {
cellWidths = this._getCellWidths(firstRow, recalculateCellWidths);

startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);
},
write: () => {
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], i === lastStickyStart);
}

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

if (stickyEndStates[i]) {
this._addStickyStyle(cell, end, endPositions[i], i === firstStickyEnd);
if (this._positionListener) {
this._positionListener.stickyColumnsUpdated({
sizes:
lastStickyStart === -1
? []
: cellWidths
.slice(0, lastStickyStart + 1)
.map((width, index) => (stickyStartStates[index] ? width : null)),
});
this._positionListener.stickyEndColumnsUpdated({
sizes:
firstStickyEnd === -1
? []
: cellWidths
.slice(firstStickyEnd)
.map((width, index) =>
stickyEndStates[index + firstStickyEnd] ? width : null,
)
.reverse(),
});
}
}
}

if (this._positionListener) {
this._positionListener.stickyColumnsUpdated({
sizes:
lastStickyStart === -1
? []
: cellWidths
.slice(0, lastStickyStart + 1)
.map((width, index) => (stickyStartStates[index] ? width : null)),
});
this._positionListener.stickyEndColumnsUpdated({
sizes:
firstStickyEnd === -1
? []
: cellWidths
.slice(firstStickyEnd)
.map((width, index) => (stickyEndStates[index + firstStickyEnd] ? width : null))
.reverse(),
});
}
});
},
},
{injector: this._tableInjector},
);
}

/**
Expand All @@ -214,64 +230,70 @@ export class StickyStyler {
return;
}

// Coalesce with other sticky row updates (top/bottom), sticky columns updates
// (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
// If positioning the rows to the bottom, reverse their order when evaluating the sticky
// position such that the last row stuck will be "bottom: 0px" and so on. Note that the
// sticky states need to be reversed as well.
const rows = position === 'bottom' ? rowsToStick.slice().reverse() : rowsToStick;
const states = position === 'bottom' ? stickyStates.slice().reverse() : stickyStates;

// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
const stickyOffsets: number[] = [];
const stickyCellHeights: (number | undefined)[] = [];
const elementsToStick: HTMLElement[][] = [];

for (let rowIndex = 0, stickyOffset = 0; rowIndex < rows.length; rowIndex++) {
if (!states[rowIndex]) {
continue;
}
// If positioning the rows to the bottom, reverse their order when evaluating the sticky
// position such that the last row stuck will be "bottom: 0px" and so on. Note that the
// sticky states need to be reversed as well.
const rows = position === 'bottom' ? rowsToStick.slice().reverse() : rowsToStick;
const states = position === 'bottom' ? stickyStates.slice().reverse() : stickyStates;

stickyOffsets[rowIndex] = stickyOffset;
const row = rows[rowIndex];
elementsToStick[rowIndex] = this._isNativeHtmlTable
? (Array.from(row.children) as HTMLElement[])
: [row];

const height = this._retrieveElementSize(row).height;
stickyOffset += height;
stickyCellHeights[rowIndex] = height;
}
// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
const stickyOffsets: number[] = [];
const stickyCellHeights: (number | undefined)[] = [];
const elementsToStick: HTMLElement[][] = [];

const borderedRowIndex = states.lastIndexOf(true);

for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
if (!states[rowIndex]) {
continue;
}

const offset = stickyOffsets[rowIndex];
const isBorderedRowIndex = rowIndex === borderedRowIndex;
for (const element of elementsToStick[rowIndex]) {
this._addStickyStyle(element, position, offset, isBorderedRowIndex);
}
}
// Coalesce with other sticky row updates (top/bottom), sticky columns updates
// (and potentially other changes like column resize).
afterNextRender(
{
earlyRead: () => {
for (let rowIndex = 0, stickyOffset = 0; rowIndex < rows.length; rowIndex++) {
if (!states[rowIndex]) {
continue;
}

stickyOffsets[rowIndex] = stickyOffset;
const row = rows[rowIndex];
elementsToStick[rowIndex] = this._isNativeHtmlTable
? (Array.from(row.children) as HTMLElement[])
: [row];

const height = this._retrieveElementSize(row).height;
stickyOffset += height;
stickyCellHeights[rowIndex] = height;
}
},
write: () => {
const borderedRowIndex = states.lastIndexOf(true);

for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
if (!states[rowIndex]) {
continue;
}

const offset = stickyOffsets[rowIndex];
const isBorderedRowIndex = rowIndex === borderedRowIndex;
for (const element of elementsToStick[rowIndex]) {
this._addStickyStyle(element, position, offset, isBorderedRowIndex);
}
}

if (position === 'top') {
this._positionListener?.stickyHeaderRowsUpdated({
sizes: stickyCellHeights,
offsets: stickyOffsets,
elements: elementsToStick,
});
} else {
this._positionListener?.stickyFooterRowsUpdated({
sizes: stickyCellHeights,
offsets: stickyOffsets,
elements: elementsToStick,
});
}
});
if (position === 'top') {
this._positionListener?.stickyHeaderRowsUpdated({
sizes: stickyCellHeights,
offsets: stickyOffsets,
elements: elementsToStick,
});
} else {
this._positionListener?.stickyFooterRowsUpdated({
sizes: stickyCellHeights,
offsets: stickyOffsets,
elements: elementsToStick,
});
}
},
},
{injector: this._tableInjector},
);
}

/**
Expand All @@ -286,17 +308,30 @@ export class StickyStyler {
}

// Coalesce with other sticky updates (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
const tfoot = tableElement.querySelector('tfoot')!;

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

if (tfoot) {
if (stickyStates.some(state => !state)) {
this._removeStickyStyle(tfoot, ['bottom']);
} else {
this._addStickyStyle(tfoot, 'bottom', 0, false);
}
}
},
},
{injector: this._tableInjector},
);
}

destroy() {
if (this._stickyColumnsReplayTimeout) {
clearTimeout(this._stickyColumnsReplayTimeout);
}

this._destroyed = true;
}

/**
Expand Down Expand Up @@ -516,6 +551,10 @@ export class StickyStyler {
}

this._stickyColumnsReplayTimeout = setTimeout(() => {
if (this._destroyed) {
return;
}

for (const update of this._updatedStickyColumnsParamsToReplay) {
this.updateStickyColumns(
update.rows,
Expand Down
Loading

0 comments on commit 5957299

Please sign in to comment.