Skip to content
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

perf(cdk/table): Use afterNextRender for sticky styling. Fixes a perf… #30242

Merged
merged 1 commit into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 153 additions & 109 deletions src/cdk/table/sticky-styler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* 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';
Expand Down Expand Up @@ -41,6 +42,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 @@ -55,6 +57,7 @@ export class StickyStyler {
* the component stylesheet for _stickCellCss.
* @param _positionListener A listener that is notified of changes to sticky rows/columns
* and their dimensions.
* @param _tableInjector The table's Injector.
*/
constructor(
private _isNativeHtmlTable: boolean,
Expand All @@ -64,6 +67,7 @@ export class StickyStyler {
private _isBrowser = true,
private readonly _needsPositionStickyOnElement = true,
private readonly _positionListener?: StickyPositioningListener,
private readonly _tableInjector?: Injector,
mmalerba marked this conversation as resolved.
Show resolved Hide resolved
) {
this._borderCellCss = {
'top': `${_stickCellCss}-border-elem-top`,
Expand Down Expand Up @@ -92,17 +96,16 @@ 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);
}
this._afterNextRender({
write: () => {
for (const element of elementsToClear) {
this._removeStickyStyle(element, stickyDirections);
}
},
});
}

Expand Down Expand Up @@ -147,53 +150,61 @@ 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);
}

if (stickyEndStates[i]) {
this._addStickyStyle(cell, end, endPositions[i], i === firstStickyEnd);
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[];

this._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 (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(),
});
}
},
});
}

Expand All @@ -214,63 +225,66 @@ export class StickyStyler {
return;
}

// 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[][] = [];

// 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;
}
this._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];
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;
}
const height = this._retrieveElementSize(row).height;
stickyOffset += height;
stickyCellHeights[rowIndex] = height;
}
},
write: () => {
const borderedRowIndex = states.lastIndexOf(true);

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

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);
}
}

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,
});
}
},
});
}

Expand All @@ -286,19 +300,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);
this._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);
}
}
}
},
});
}

/** Triggered by the table's OnDestroy hook. */
destroy() {
if (this._stickyColumnsReplayTimeout) {
clearTimeout(this._stickyColumnsReplayTimeout);
}

this._destroyed = true;
}

/**
* Removes the sticky style on the element by removing the sticky cell CSS class, re-evaluating
* the zIndex, removing each of the provided sticky directions, and removing the
Expand Down Expand Up @@ -516,6 +541,10 @@ export class StickyStyler {
}

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

for (const update of this._updatedStickyColumnsParamsToReplay) {
this.updateStickyColumns(
update.rows,
Expand All @@ -530,6 +559,21 @@ export class StickyStyler {
}, 0);
}
}

/**
* Invoke afterNextRender with the table's injector, falling back to CoalescedStyleScheduler
* if the injector was not provided.
*/
private _afterNextRender(spec: {earlyRead?: () => void; write: () => void}) {
if (this._tableInjector) {
afterNextRender(spec, {injector: this._tableInjector});
} else {
this._coalescedStyleScheduler.schedule(() => {
spec.earlyRead?.();
spec.write();
});
}
}
}

function isCell(element: Element) {
Expand Down
Loading
Loading